Compare commits

...
9 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.6 47e39d7820 feat: v3.0.0 — OCP CLI, plan usage monitoring, runtime settings, prompt guard
Major release: complete management interface for the Claude proxy.

- /ocp CLI with 10 subcommands (usage, status, health, settings, logs, models, sessions, clear, restart)
- Gateway plugin for /ocp as native Telegram/Discord slash command
- Plan usage monitoring via Anthropic API rate-limit headers (session %, weekly %, reset times)
- Per-model request stats (count, avg/max elapsed, avg/max prompt chars)
- Runtime settings via PATCH /settings (tune timeouts, concurrency, prompt limits without restart)
- Prompt truncation guard at 150K chars to prevent conversation history blowup
- Circuit breaker removed — caused cascading failures in CLI-proxy architecture
- Timeout increases: Opus 150s, Sonnet 120s, Haiku 45s base first-byte
- New endpoints: /usage, /status, /settings, /logs
- README fully rewritten with CLI examples and changelog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:33:17 +10:00
taodengandClaude Opus 4.6 7434f6adc0 docs: v2.5.0 release notes — sliding-window circuit breaker incident writeup
Document the 2026-03-22 multi-agent cascading timeout incident, root cause
analysis, and all new/changed defaults. Update env vars table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:48:23 +10:00
taodengandClaude Opus 4.6 8a7951134e feat: v2.5.0 — sliding-window circuit breaker, graduated backoff, increased timeouts
Emergency fix for multi-agent (ClawTeam) burst scenario that caused cascading
failures: when multiple Opus agents ran concurrently, the old consecutive-count
breaker (threshold=3) tripped within seconds, blocking ALL subsequent requests
globally — including non-ClawTeam agents and new sessions.

Changes:
- Circuit breaker now uses a sliding time window (default 5min) instead of
  consecutive failure count. 6 failures in 5min triggers open, vs old 3-in-a-row.
- Half-open state allows 2 concurrent probe requests (was 1), improving recovery
  speed when API comes back.
- Graduated backoff: cooldown doubles on each re-open (120s→240s→300s cap),
  resets fully on first success.
- Increased default timeouts: Opus first-byte 60s→90s, Sonnet 45s→60s,
  overall 120s→300s, max concurrent 5→8.
- /health endpoint now exposes per-model breaker state, window stats, and
  marks status as "degraded" when any breaker is open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:47:23 +10:00
47434a8ba7 fix: security hardening + real streaming (#4)
* Add graceful shutdown handling for SIGTERM and SIGINT

On receiving SIGTERM or SIGINT, the server now:
1. Stops accepting new connections (server.close)
2. Clears session cleanup and auth check intervals
3. Sends SIGTERM to all active child processes
4. Waits up to 5s for processes to exit, then SIGKILL + exit(1)
5. Exits cleanly with exit(0) once all processes are gone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: security hardening — bind localhost, validate models, limit body size

- Bind to 127.0.0.1 instead of 0.0.0.0 to prevent network exposure
- Restrict CORS Access-Control-Allow-Origin to http://127.0.0.1
- Add 10MB request body size limit (413 on exceed)
- Validate model parameter against known MODEL_MAP keys (400 on unknown)
- Remove catch-all POST route; only /v1/chat/completions accepted
- Use crypto.timingSafeEqual for API key comparison
- Sanitize error messages to strip internal file paths

Bumps version to 2.4.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace fake streaming with real stdout-piped SSE streaming

Previously, streaming requests waited for the entire claude CLI process
to complete, then split the output into artificial 500-char chunks sent
as SSE events. This defeated the purpose of streaming and added latency.

Now, stdout from the claude child process is piped directly to SSE
chunks as data arrives. Each `data` event from the process stdout
becomes an immediate SSE delta event, giving clients real incremental
output. The shared process setup logic is extracted into
spawnClaudeProcess() to avoid duplication between streaming and
non-streaming paths. Client disconnect detection kills the child process
to free resources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: tighten CORS to dynamic localhost-only and reduce body limit to 5MB

- CORS now dynamically matches localhost/127.0.0.1 origins instead of
  static http://127.0.0.1, preventing cross-origin abuse while
  supporting any localhost port
- Body size limit reduced from 10MB to 5MB to limit OOM risk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 18:46:36 +10:00
taodengandClaude Opus 4.6 1d95dbeebc feat: v2.4.0 — per-model circuit breaker, adaptive timeout tiers, structured logging
- Circuit breaker: consecutive timeouts (default 3) mark model as degraded for 60s,
  failing fast instead of blocking gateway with repeated timeout attempts
- Per-model timeout tiers: Opus 60s base, Sonnet 45s, Haiku 30s, plus adaptive
  scaling by prompt size (~15s/100k chars for Opus)
- Structured JSON logging for spawns, timeouts, breaker state changes
- Late close guard: prevents double-settle when timeout fires before proc.close

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:38:45 +10:00
taodeng 9e6bef729b fix: harden proxy timeout handling for opus 2026-03-21 17:32:21 +10:00
taodeng 256b2bfb77 docs: ship v2.3.0 positioning and coexistence guide 2026-03-21 14:02:33 +10:00
taodeng 384e66bfa6 feat: v2.2.0 — faster fallback with first-byte timeout
- Reduced default CLAUDE_TIMEOUT from 300s to 120s for faster fallback
- Added CLAUDE_FIRST_BYTE_TIMEOUT (default 30s): aborts early if Claude
  CLI produces no output, preventing silent hangs
- First-byte timing logged for every request for observability
- Health endpoint now reports firstByteTimeout in config
2026-03-21 13:57:23 +10:00
taodengandClaude Opus 4.6 76a8c56c88 feat: v2.1.0 — faster fallback with first-byte timeout and reduced defaults
- Default timeout: 300s → 120s (CLAUDE_TIMEOUT)
- New: first-byte timeout at 30s (CLAUDE_FIRST_BYTE_TIMEOUT) — aborts early
  if Claude CLI produces no stdout, triggering faster fallback
- Pool disabled by default (POOL_SIZE=0) — on-demand spawning is now the
  default; set CLAUDE_POOL_SIZE>0 to re-enable
- Health endpoint now reports timeout and firstByteTimeout values
- Startup log shows pool mode (disabled/on-demand vs active)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:53:27 +10:00
10 changed files with 2312 additions and 607 deletions
+209 -170
View File
@@ -1,32 +1,108 @@
# openclaw-claude-proxy
# openclaw-claude-proxy (OCP)
> **Already paying for Claude Pro/Max? Use it as your OpenClaw model provider — $0 extra API cost.**
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.
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.
## v2.0.0 — Major Upgrade
## What's New in v3.0.0
**What's new:**
- **On-demand spawning** — eliminates the pool crash loops, DEGRADED states, and stdin timeout errors from v1.x. Each request spawns a fresh `claude -p` process with stdin written immediately. No more stale workers, no more backoff spirals.
- **Session management** — multi-turn conversations use `--resume` to avoid resending full history. Reduces token waste and enables Claude Code's built-in context compression on long conversations.
- **Full tool access** — expanded default tools (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Agent). Configurable via `CLAUDE_ALLOWED_TOOLS` or bypass all checks with `CLAUDE_SKIP_PERMISSIONS=true`.
- **System prompt pass-through** — set `CLAUDE_SYSTEM_PROMPT` to inject context into every request.
- **MCP config support** — set `CLAUDE_MCP_CONFIG` to load MCP servers (Telegram, etc.) into claude -p calls.
- **Concurrency control** — `CLAUDE_MAX_CONCURRENT` prevents runaway process spawning (default: 5).
- **Auth health monitoring** — periodic `claude auth status` checks with status exposed on `/health`.
- **Session API** — `GET /sessions` to list, `DELETE /sessions` to clear active sessions.
- **Improved diagnostics** — `/health` endpoint shows stats, active sessions, recent errors, auth status, and full config.
### `/ocp` — Your Proxy Command Center
**Coexistence with Claude Code interactive mode:**
OCP and Claude Code (interactive/Telegram) run on completely different paths and can coexist on the same machine without conflict:
- OCP: `localhost:3456` (HTTP) → spawns `claude -p` processes (per-request, stateless)
- CC: MCP protocol (in-process) → persistent interactive session
- No shared ports, no shared processes, no shared sessions
Full management interface available from Telegram, Discord, or any terminal.
**Daemon advantage over CC:**
OCP runs as a system daemon (launchd/systemd) that auto-starts on boot and auto-recovers from crashes. Unlike Claude Code interactive mode, OCP does not require a terminal session to stay open — it survives disconnects, reboots, and SSH drops. Combined with OpenClaw's memory system, this means your agents never lose continuity.
```
$ ocp usage
Plan Usage Limits
─────────────────────────────────────
Current session 3% used
Resets in 4h 32m (Tue, Mar 24, 10:00 PM)
## How it works
Weekly (all models) 3% used
Resets in 6d 6h (Tue, Mar 31, 12:00 AM)
Extra usage off
Model Stats
Model Req OK Er AvgT MaxT AvgP MaxP
──────────────────────────────────────────────────────
haiku 1 1 0 6s 6s 0K 0K
opus 2 2 0 20s 26s 42K 43K
sonnet 2 2 0 24s 24s 41K 41K
Total 5
Proxy: up 0h 37m | 5 reqs | 0 err | 0 timeout
```
**All commands:**
```
$ ocp --help
ocp usage Plan usage limits & model stats
ocp status Quick overview
ocp health Proxy diagnostics
ocp settings View tunable settings
ocp settings <k> <v> Update a setting at runtime
ocp logs [N] [level] Recent logs (default: 20, error)
ocp models Available models
ocp sessions Active sessions
ocp clear Clear all sessions
ocp restart Restart proxy
ocp restart gateway Restart gateway
```
In **Telegram/Discord**, use `/ocp usage`, `/ocp settings`, etc. — registered as a native slash command via the OCP gateway plugin.
### Runtime Settings (No Restart Needed)
```
$ ocp settings
OCP Settings
─────────────────────────────────────
timeout 300000 ms Overall request timeout
firstByteTimeout 90000 ms Base first-byte timeout
maxConcurrent 8 Max concurrent claude processes
sessionTTL 3600000 ms Session idle expiry
maxPromptChars 150000 chars Prompt truncation limit
Timeout Tiers (first-byte):
opus base=150000ms perChar=0.0005
sonnet base=120000ms perChar=0.0005
haiku base= 45000ms perChar=0.0001
```
Change any setting live:
```
$ ocp settings maxPromptChars 200000
✓ maxPromptChars = 200000
$ ocp settings maxConcurrent 999
✗ maxConcurrent: value 999 out of range [1, 32]
```
### Circuit Breaker Removed
The v2.5.0 circuit breaker has been **removed entirely**. It was designed for direct API connections but caused cascading failures in the CLI-proxy architecture — once API got briefly slow, the breaker blocked ALL agents for 120s+, making the problem worse. With CLI spawning, timeouts are transient and don't benefit from back-off.
### Prompt Truncation Guard
New safety valve prevents runaway context from conversation history accumulation (a recurring issue where prompts balloon from 40K to 400K+ chars).
- Default limit: **150K characters** (configurable via `maxPromptChars`)
- When exceeded: keeps system messages + as many recent messages as fit
- Logs `prompt_truncated` events for monitoring
### Increased Timeouts
| Model | Old Base | New Base | Per 100K chars |
|-------|----------|----------|----------------|
| Opus | 90s | **150s** | +50s |
| Sonnet | 60s | **120s** | +50s |
| Haiku | 30s | **45s** | +10s |
---
## How It Works
```
OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via OAuth)
@@ -34,13 +110,7 @@ OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via
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.
## Prerequisites
- **Node.js** >= 18
- **Claude CLI** installed and authenticated (`claude login`)
- **OpenClaw** installed
## Quick Start (Node.js)
## Quick Start
```bash
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
@@ -50,119 +120,69 @@ cd openclaw-claude-proxy
node setup.mjs
```
That's it. The setup script will:
The setup script will:
1. Verify Claude CLI is installed and authenticated
2. Add `claude-local` provider to `openclaw.json`
3. Add auth profiles to all agents
4. Start the proxy
5. Install auto-start on login (launchd on macOS, systemd on Linux)
3. Start the proxy and install auto-start (launchd on macOS, systemd on Linux)
Then set your preferred Claude model as default:
Then set your preferred model:
```bash
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
openclaw config set agents.defaults.model.primary "claude-local/claude-sonnet-4-6"
openclaw gateway restart
```
## Session Management (v2.0)
### Install the CLI
Multi-turn conversations can use sessions to avoid resending full message history on every request.
The `ocp` command is included in the repo:
**How to enable:** Include a `session_id` or `conversation_id` field in your request body, or set the `X-Session-Id` / `X-Conversation-Id` header.
```bash
# Option 1: symlink to PATH
ln -sf $(pwd)/ocp /usr/local/bin/ocp
# Option 2: npm link (if installed globally)
npm link
```
### Install the Gateway Plugin (for Telegram/Discord)
Copy the plugin to the OpenClaw extensions directory:
```bash
cp -r ocp-plugin/ ~/.openclaw/extensions/ocp/
# Or into the bundled extensions:
cp -r ocp-plugin/ /opt/homebrew/lib/node_modules/openclaw/dist/extensions/ocp/
```
Add to `~/.openclaw/openclaw.json`:
```json
{
"model": "claude-opus-4-6",
"session_id": "conv-abc-123",
"messages": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "What did I just say?"}
]
"plugins": {
"allow": ["ocp"],
"entries": { "ocp": { "enabled": true } }
}
}
```
**First request** with a new session_id: all messages are sent, session is persisted via `--session-id`.
**Subsequent requests** with the same session_id: only the latest user message is sent via `--resume`, reducing token consumption.
Restart the gateway: `openclaw gateway restart`
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
## API Endpoints
**API endpoints:**
- `GET /sessions` — list all active sessions
- `DELETE /sessions` — clear all sessions
## Security
- **Localhost only** — the proxy binds to `127.0.0.1` and is not exposed to the internet or your local network
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require a Bearer token on all requests (except `/health`). When unset, auth is disabled for backwards compatibility
- **No API keys for Claude** — authentication to Anthropic goes through Claude CLI's OAuth session, no Anthropic credentials are stored in the proxy
- **Auto-start via launchd/systemd** — `node setup.mjs` installs a user-level launch agent (macOS) or systemd user service (Linux) so the proxy starts automatically on login
- **Remove auto-start** at any time:
```bash
node uninstall.mjs
```
## Manual Install
### 1. Start the proxy
```bash
node server.mjs
# or in background:
bash start.sh
```
### 2. Configure OpenClaw
Add to `~/.openclaw/openclaw.json` under `models.providers`:
```json
"claude-local": {
"baseUrl": "http://127.0.0.1:3456/v1",
"api": "openai-completions",
"apiKey": "<your PROXY_API_KEY, or omit if auth disabled>",
"models": [
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"reasoning": true,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"reasoning": true,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-haiku-4",
"name": "Claude Haiku 4",
"reasoning": false,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 200000,
"maxTokens": 8192
}
]
}
```
### 3. Set as default model
```bash
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
openclaw gateway restart
```
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
| `/health` | GET | Comprehensive health check |
| `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) |
| `/settings` | GET | View tunable settings |
| `/settings` | PATCH | Update settings at runtime |
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
| `/sessions` | GET | List active sessions |
| `/sessions` | DELETE | Clear all sessions |
## Available Models
| Model ID | Claude CLI model | Notes |
| Model ID | Claude CLI Model | Notes |
|----------|-----------------|-------|
| `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
@@ -174,83 +194,102 @@ openclaw gateway restart
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `300000` | Request timeout (ms) |
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms) |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Set `true` to 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 file |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry in ms (default: 1 hour) |
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent claude processes |
| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON |
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
## API Endpoints
## Session Management
- `GET /v1/models` — List available models
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming)
- `GET /health` — Comprehensive health check (stats, sessions, auth, config)
- `GET /sessions` — List active sessions
- `DELETE /sessions` — Clear all sessions
Multi-turn conversations use `--resume` to avoid resending full history on every request.
## Authentication
Include a `session_id` field in the request body or `X-Session-Id` header:
The proxy supports optional Bearer token authentication via the `PROXY_API_KEY` environment variable.
**When `PROXY_API_KEY` is set**, all requests (except `GET /health`) must include a valid `Authorization: Bearer <token>` header. Requests with a missing or invalid token receive a `401 Unauthorized` response.
**When `PROXY_API_KEY` is not set**, authentication is disabled and all requests are accepted.
```bash
# Start with auth enabled
PROXY_API_KEY=my-secret-token node server.mjs
```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?"}
]
}
```
## Architecture: v1 vs v2
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
| | v1.x (pool) | v2.0 (on-demand) |
## Security
- **Localhost only** — binds to `127.0.0.1`, not exposed to the network
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth on all requests except `/health`
- **No API keys** — authentication to Anthropic goes through Claude CLI's OAuth session
- **Auto-start** — launchd (macOS) / systemd (Linux) via `node setup.mjs`
- **Remove auto-start**: `node uninstall.mjs`
## Architecture
| | v1.x (pool) | v2.0+ (on-demand) |
|---|---|---|
| Process lifecycle | Pre-spawn idle workers | Spawn per request |
| Crash handling | Backoff → DEGRADED → manual restart | No crash loops (no idle workers) |
| Session support | None (stateless) | --resume with session tracking |
| Tool access | 6 tools hardcoded | Configurable, expanded defaults |
| System prompt | None | CLAUDE_SYSTEM_PROMPT env |
| MCP support | None | CLAUDE_MCP_CONFIG env |
| Concurrency | Unlimited (dangerous) | CLAUDE_MAX_CONCURRENT limit |
| Auth monitoring | None | Periodic health checks |
| Diagnostics | Basic /health | Full stats, sessions, errors |
| 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 (including Telegram bots) are completely independent:
OCP and Claude Code interactive mode are completely independent:
| | OCP (this proxy) | CC interactive |
| | OCP (this proxy) | Claude Code |
|---|---|---|
| Protocol | HTTP (localhost:3456) | MCP (in-process) |
| Process model | Per-request spawn | Persistent session |
| Lifecycle | Daemon (auto-start, auto-recover) | Requires terminal |
| Permission model | Pre-approved tools | Interactive prompts |
| Lifecycle | Daemon (auto-start) | Requires terminal |
| Use case | Automated agent work | Human-in-the-loop |
Both can run on the same machine simultaneously. No shared state, no port conflicts.
Both run on the same machine simultaneously. No shared state, no port conflicts.
## Recovery after OpenClaw upgrade
OpenClaw upgrades (`npm update -g openclaw`) **do not overwrite** the user config at `~/.openclaw/openclaw.json`. However, if the claude-local models stop working after an upgrade:
### One-command recovery
## Recovery After OpenClaw Upgrade
```bash
cd ~/.openclaw/projects/claude-proxy # or wherever you cloned it
git pull # pull latest version
node setup.mjs # reconfigure OpenClaw + start proxy
cd ~/.openclaw/projects/claude-proxy
git pull
node setup.mjs
openclaw gateway restart
```
## Notes
## Changelog
- Cost shows as $0 because billing goes through your Claude subscription
- Each request spawns a `claude -p` process; concurrent requests are capped by `CLAUDE_MAX_CONCURRENT`
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
- Session data is stored by Claude CLI on disk; session map is in-memory (lost on proxy restart)
### 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
Executable
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env bash
# ocp — OpenClaw Proxy CLI
# Usage: ocp <command> [args]
#
# Talks to the local claude-proxy at http://127.0.0.1:3456
set -euo pipefail
PROXY="http://127.0.0.1:3456"
_json() { python3 -m json.tool 2>/dev/null || cat; }
_bar() {
local pct=$1 width=20
local filled=$(( pct * width / 100 ))
local empty=$(( width - filled ))
printf '['
printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true
printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true
printf '] %d%%' "$pct"
}
# ── usage ────────────────────────────────────────────────────────────────
cmd_usage_help() {
cat <<'EOF'
ocp usage — Show Claude plan usage limits
Displays current session utilization, weekly limits, extra usage status,
and proxy request statistics. Data is fetched from the Anthropic API
via a minimal probe call (cached for 5 minutes).
Usage: ocp usage
EOF
}
cmd_usage() {
local data
data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; }
echo "$data" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
p = d['plan']
s = p['currentSession']
w = p['weeklyLimits']['allModels']
e = p['extraUsage']
px = d['proxy']
models = d.get('models', {})
print('Plan Usage Limits')
print('─────────────────────────────────────')
print(f' Current session {s[\"percent\"]:>4} used')
print(f' Resets in {s[\"resetsIn\"]} ({s[\"resetsAtHuman\"]})')
print()
print(f' Weekly (all models) {w[\"percent\"]:>4} used')
print(f' Resets in {w[\"resetsIn\"]} ({w[\"resetsAtHuman\"]})')
print()
status_icon = 'on' if e['status'] == 'allowed' else 'off'
print(f' Extra usage {status_icon}')
print()
if models:
print('Model Stats (since proxy start)')
print('─────────────────────────────────────────────────────────────────────')
hdr = f' {\"Model\":<25} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Max Time\":>9} {\"Avg Prompt\":>11} {\"Max Prompt\":>11}'
print(hdr)
print(' ' + '─' * (len(hdr) - 2))
total_reqs = 0
for model in sorted(models):
m = models[model]
total_reqs += m['requests']
avg_t = f'{m[\"avgElapsed\"]/1000:.0f}s' if m['avgElapsed'] else '-'
max_t = f'{m[\"maxElapsed\"]/1000:.0f}s' if m['maxElapsed'] else '-'
avg_p = f'{m[\"avgPromptChars\"]/1000:.0f}K' if m['avgPromptChars'] else '-'
max_p = f'{m[\"maxPromptChars\"]/1000:.0f}K' if m['maxPromptChars'] else '-'
print(f' {model:<25} {m[\"requests\"]:>5} {m[\"successes\"]:>4} {m[\"errors\"]:>4} {avg_t:>9} {max_t:>9} {avg_p:>11} {max_p:>11}')
print(f' {\"Total\":<25} {total_reqs:>5}')
else:
print(' No model requests yet.')
print()
print(f'Proxy: up {px[\"uptime\"]} | {px[\"totalRequests\"]} reqs | {px[\"activeRequests\"]} active | {px[\"errors\"]} err | {px[\"timeouts\"]} timeout')
"
}
# ── status ───────────────────────────────────────────────────────────────
cmd_status_help() {
cat <<'EOF'
ocp status — Quick combined overview (usage + health)
Usage: ocp status
EOF
}
cmd_status() {
curl -sf --max-time 15 "$PROXY/status" | _json
}
# ── health ───────────────────────────────────────────────────────────────
cmd_health_help() {
cat <<'EOF'
ocp health — Proxy health and diagnostics
Shows proxy status, version, uptime, auth, config, sessions, and recent errors.
Usage: ocp health
EOF
}
cmd_health() {
curl -sf --max-time 10 "$PROXY/health" | _json
}
# ── logs ─────────────────────────────────────────────────────────────────
cmd_logs_help() {
cat <<'EOF'
ocp logs — Show recent proxy log entries
Usage: ocp logs [N] [LEVEL]
Arguments:
N Number of entries to show (default: 20, max: 200)
LEVEL Filter level: error, warn, info, all (default: error)
Examples:
ocp logs Last 20 errors
ocp logs 50 all Last 50 entries of any level
ocp logs 10 warn Last 10 warnings
EOF
}
cmd_logs() {
local n=${1:-20}
local level=${2:-error}
curl -sf --max-time 10 "$PROXY/logs?n=$n&level=$level" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
entries = d.get('entries', [])
if not entries:
print(f'No {d.get(\"level\",\"\")} log entries.')
else:
for e in entries:
if 'raw' in e:
print(e['raw'][:200])
else:
ts = e.get('ts','')[:19]
ev = e.get('event','?')
lvl = e.get('level','')
model = e.get('model','')
extra = ' '.join(f'{k}={v}' for k,v in e.items() if k not in ('ts','event','level','model'))
parts = [ts, lvl.upper(), ev]
if model: parts.append(model)
if extra: parts.append(extra)
print(' | '.join(parts))
"
}
# ── models ───────────────────────────────────────────────────────────────
cmd_models_help() {
cat <<'EOF'
ocp models — List available Claude models
Usage: ocp models
EOF
}
cmd_models() {
curl -sf --max-time 5 "$PROXY/v1/models" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
for m in d.get('data', []):
print(f\" {m['id']}\")
"
}
# ── sessions ─────────────────────────────────────────────────────────────
cmd_sessions_help() {
cat <<'EOF'
ocp sessions — List active CLI sessions
Usage: ocp sessions
EOF
}
cmd_sessions() {
curl -sf --max-time 5 "$PROXY/sessions" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
sessions = d.get('sessions', [])
if not sessions:
print('No active sessions.')
else:
for s in sessions:
print(f\" {s['id'][:16]}... model={s['model']} msgs={s['messages']} last={s['lastUsed']}\")
"
}
# ── clear ────────────────────────────────────────────────────────────────
cmd_clear_help() {
cat <<'EOF'
ocp clear — Clear all active CLI sessions
Usage: ocp clear
EOF
}
cmd_clear() {
local result
result=$(curl -sf --max-time 5 -X DELETE "$PROXY/sessions")
local count
count=$(echo "$result" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['cleared'])")
echo "Cleared $count sessions."
}
# ── restart ──────────────────────────────────────────────────────────────
cmd_restart_help() {
cat <<'EOF'
ocp restart — Restart the proxy or gateway
Usage:
ocp restart Restart the Claude proxy service
ocp restart gateway Restart the OpenClaw gateway
(briefly disconnects all Telegram/Discord bots)
EOF
}
cmd_restart() {
if [[ "${1:-}" == "gateway" ]]; then
echo "Restarting gateway..."
openclaw gateway restart 2>&1
else
echo "Restarting proxy..."
launchctl kickstart -k gui/501/ai.openclaw.proxy 2>/dev/null || {
echo "launchctl failed, trying kill + restart..."
pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
sleep 1
cd "$HOME/.openclaw/projects/claude-proxy" && nohup node server.mjs >> "$HOME/.openclaw/logs/proxy.log" 2>&1 &
}
sleep 3
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
echo "✓ Proxy restarted successfully."
cmd_usage
else
echo "✗ Proxy not responding after restart."
fi
fi
}
# ── settings ─────────────────────────────────────────────────────────────
cmd_settings_help() {
cat <<'EOF'
ocp settings — View or update proxy settings at runtime
Usage:
ocp settings Show all tunable settings
ocp settings <key> <value> Update a setting (no restart needed)
Tunable keys:
timeout Overall request timeout (ms) [30000 - 600000]
firstByteTimeout Base first-byte timeout (ms) [15000 - 300000]
maxConcurrent Max concurrent claude processes [1 - 32]
sessionTTL Session idle expiry (ms) [60000 - 86400000]
maxPromptChars Prompt truncation limit (chars) [10000 - 1000000]
tiers.opus.base Opus first-byte timeout base (ms) [30000 - 600000]
tiers.opus.perChar Opus per-char timeout (ms/char) [0 - 0.01]
tiers.sonnet.base Sonnet first-byte timeout base (ms) [30000 - 600000]
tiers.sonnet.perChar Sonnet per-char timeout (ms/char) [0 - 0.01]
tiers.haiku.base Haiku first-byte timeout base (ms) [15000 - 300000]
tiers.haiku.perChar Haiku per-char timeout (ms/char) [0 - 0.01]
Examples:
ocp settings maxPromptChars 200000
ocp settings tiers.sonnet.base 150000
EOF
}
cmd_settings() {
if [[ -z "${1:-}" ]]; then
# GET — show all settings
curl -sf --max-time 5 "$PROXY/settings" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
print('OCP Settings')
print('─────────────────────────────────────')
for k in ('timeout','firstByteTimeout','maxConcurrent','sessionTTL','maxPromptChars'):
v = d.get(k, {})
val = v.get('value', '?')
unit = v.get('unit', '')
desc = v.get('desc', '')
print(f' {k:<20} {val:>8} {unit:<6} {desc}')
t = d.get('tiers', {})
print()
print('Timeout Tiers (first-byte):')
for tier in ('opus','sonnet','haiku'):
info = t.get(tier, {})
print(f' {tier:<8} base={info.get(\"base\",\"?\"):>6}ms perChar={info.get(\"perPromptChar\",\"?\")}')
"
elif [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cmd_settings_help
elif [[ -z "${2:-}" ]]; then
echo "Usage: ocp settings <key> <value>"
echo "Run 'ocp settings --help' for available keys."
return 1
else
# PATCH — update a setting
local key="$1"
local value="$2"
local result
result=$(curl -s --max-time 5 -X PATCH "$PROXY/settings" \
-H "Content-Type: application/json" \
-d "{\"$key\": $value}" 2>&1)
if echo "$result" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); errs=d.get('errors',[]); exit(1 if errs else 0)" 2>/dev/null; then
echo "✓ $key = $value"
else
echo "$result" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
for e in d.get('errors', []):
print(f'✗ {e}')
" 2>/dev/null || echo "✗ $result"
fi
fi
}
# ── help ─────────────────────────────────────────────────────────────────
cmd_help() {
cat <<'EOF'
ocp — OpenClaw Proxy CLI
Usage: ocp <command> [args]
Commands:
usage Plan usage limits
status Quick overview (usage + health)
health Proxy diagnostics
settings View or update tunable settings
logs [N] [level] Recent logs (default: 20, error)
models Available models
sessions Active sessions
clear Clear all sessions
restart Restart proxy
restart gateway Restart gateway
Run 'ocp <command> --help' for details on a specific command.
EOF
}
# ── dispatch ─────────────────────────────────────────────────────────────
# Check for --help/-h on any subcommand: ocp <cmd> --help
subcmd="${1:-help}"
shift 2>/dev/null || true
# Global --help
if [[ "$subcmd" == "--help" || "$subcmd" == "-h" || "$subcmd" == "help" ]]; then
cmd_help
exit 0
fi
# Per-subcommand --help
for arg in "$@"; do
if [[ "$arg" == "--help" || "$arg" == "-h" ]]; then
fn="cmd_${subcmd}_help"
if declare -f "$fn" > /dev/null 2>&1; then
"$fn"
else
echo "No help available for '$subcmd'."
fi
exit 0
fi
done
case "$subcmd" in
usage) cmd_usage ;;
status) cmd_status ;;
health) cmd_health ;;
settings) cmd_settings "${1:-}" "${2:-}" ;;
logs) cmd_logs "${1:-20}" "${2:-error}" ;;
models) cmd_models ;;
sessions) cmd_sessions ;;
clear) cmd_clear ;;
restart) cmd_restart "${1:-}" ;;
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
esac
+217
View File
@@ -0,0 +1,217 @@
/**
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
*/
const PROXY = "http://127.0.0.1:3456";
// Wrap output in monospace code block for Telegram/Discord alignment
function mono(text) { return "```\n" + text + "\n```"; }
async function fetchJSON(path) {
const resp = await fetch(`${PROXY}${path}`, { signal: AbortSignal.timeout(15000) });
if (!resp.ok) throw new Error(`proxy ${resp.status}: ${resp.statusText}`);
return resp.json();
}
function bar(pct, width = 16) {
const filled = Math.round(pct * width);
return "█".repeat(filled) + "░".repeat(width - filled);
}
function fmtMs(ms) {
return ms >= 1000 ? `${(ms / 1000).toFixed(0)}s` : `${ms}ms`;
}
function fmtChars(c) {
return c >= 1000 ? `${(c / 1000).toFixed(0)}K` : `${c}`;
}
// ── Subcommand handlers ─────────────────────────────────────────────────
async function cmdUsage() {
const d = await fetchJSON("/usage");
const s = d.plan.currentSession;
const w = d.plan.weeklyLimits.allModels;
const e = d.plan.extraUsage;
const px = d.proxy;
const models = d.models || {};
let out = "Plan Usage Limits\n";
out += "─────────────────────────────\n";
out += `Current session ${bar(s.utilization)} ${s.percent}\n`;
out += ` Resets in ${s.resetsIn} (${s.resetsAtHuman})\n\n`;
out += `Weekly (all) ${bar(w.utilization)} ${w.percent}\n`;
out += ` Resets in ${w.resetsIn} (${w.resetsAtHuman})\n\n`;
out += `Extra usage ${e.status === "allowed" ? "on" : "off"}\n\n`;
const modelNames = Object.keys(models).sort();
if (modelNames.length > 0) {
out += "Model Stats\n";
const hdr = `${"Model".padEnd(14)} ${"Req".padStart(4)} ${"OK".padStart(3)} ${"Er".padStart(3)} ${"AvgT".padStart(5)} ${"MaxT".padStart(5)} ${"AvgP".padStart(5)} ${"MaxP".padStart(5)}`;
out += hdr + "\n";
out += "─".repeat(hdr.length) + "\n";
let total = 0;
for (const name of modelNames) {
const m = models[name];
total += m.requests;
const short = name.replace("claude-", "").replace("-4-5-20251001", "").replace("-4-6", "");
out += `${short.padEnd(14)} ${String(m.requests).padStart(4)} ${String(m.successes).padStart(3)} ${String(m.errors).padStart(3)} ${fmtMs(m.avgElapsed).padStart(5)} ${fmtMs(m.maxElapsed).padStart(5)} ${fmtChars(m.avgPromptChars).padStart(5)} ${fmtChars(m.maxPromptChars).padStart(5)}\n`;
}
out += `${"Total".padEnd(14)} ${String(total).padStart(4)}\n`;
}
out += `\nProxy: up ${px.uptime} | ${px.totalRequests} reqs | ${px.errors} err | ${px.timeouts} timeout`;
return out;
}
async function cmdHealth() {
const d = await fetchJSON("/health");
let out = `Status: ${d.status} | v${d.version}\n`;
out += `Uptime: ${d.uptimeHuman}\n`;
out += `Auth: ${d.auth?.ok ? "ok" : d.auth?.message || "unknown"}\n`;
out += `Binary: ${d.claudeBinaryOk ? "ok" : "missing"}\n`;
out += `Sessions: ${d.sessions?.length || 0} active\n`;
out += `Requests: ${d.stats?.totalRequests || 0} total, ${d.stats?.activeRequests || 0} active\n`;
out += `Errors: ${d.stats?.errors || 0} | Timeouts: ${d.stats?.timeouts || 0}\n`;
if (d.recentErrors?.length) {
out += "\nRecent errors:\n";
for (const e of d.recentErrors.slice(-3)) {
out += ` ${e.time?.slice(11, 19) || "?"} ${e.message}\n`;
}
}
return out;
}
async function cmdStatus() {
const d = await fetchJSON("/status");
const icon = d.proxy?.status === "ok" ? "🟢" : d.proxy?.status === "degraded" ? "🟡" : "🔴";
let out = `${icon} ${d.proxy?.status} | v${d.proxy?.version} | up ${d.proxy?.uptime} | auth ${d.proxy?.auth}\n`;
out += `Sessions: ${d.proxy?.activeSessions || 0}\n`;
out += `Requests: ${d.requests?.total || 0} | active ${d.requests?.active || 0} | err ${d.requests?.errors || 0} | timeout ${d.requests?.timeouts || 0}\n`;
if (d.plan?.currentSession) {
out += `\nSession: ${d.plan.currentSession.percent} (resets ${d.plan.currentSession.resetsIn})\n`;
out += `Weekly: ${d.plan.weeklyLimits?.allModels?.percent} (resets ${d.plan.weeklyLimits?.allModels?.resetsIn})`;
}
return out;
}
async function cmdSettings(args) {
if (!args) {
const d = await fetchJSON("/settings");
let out = "OCP Settings\n─────────────────────────────\n";
for (const k of ["timeout", "firstByteTimeout", "maxConcurrent", "sessionTTL", "maxPromptChars"]) {
const v = d[k];
if (v) out += `${k.padEnd(20)} ${String(v.value).padStart(8)} ${(v.unit || "").padEnd(6)} ${v.desc}\n`;
}
if (d.tiers) {
out += "\nTimeout tiers:\n";
for (const t of ["opus", "sonnet", "haiku"]) {
const info = d.tiers[t];
if (info) out += ` ${t.padEnd(8)} base=${info.base}ms perChar=${info.perPromptChar}\n`;
}
}
return out;
}
// Parse "key value"
const parts = args.trim().split(/\s+/);
if (parts.length < 2 || parts[0] === "--help" || parts[0] === "-h") {
return "Usage: /ocp settings <key> <value>\nKeys: timeout, firstByteTimeout, maxConcurrent, sessionTTL, maxPromptChars, tiers.opus.base, tiers.sonnet.base, tiers.haiku.base, tiers.*.perChar";
}
const [key, val] = parts;
const numVal = Number(val);
if (isNaN(numVal)) return `Error: value must be a number, got "${val}"`;
const resp = await fetch(`${PROXY}/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: numVal }),
signal: AbortSignal.timeout(5000),
});
const d = await resp.json();
if (d.errors?.length) return `${d.errors.join("; ")}`;
return `${key} = ${numVal}`;
}
async function cmdModels() {
const d = await fetchJSON("/v1/models");
return (d.data || []).map((m) => ` ${m.id}`).join("\n") || "No models.";
}
async function cmdSessions() {
const d = await fetchJSON("/sessions");
if (!d.sessions?.length) return "No active sessions.";
return d.sessions.map((s) => ` ${s.id.slice(0, 16)}… model=${s.model} msgs=${s.messages}`).join("\n");
}
async function cmdClear() {
const resp = await fetch(`${PROXY}/sessions`, { method: "DELETE", signal: AbortSignal.timeout(5000) });
const d = await resp.json();
return `Cleared ${d.cleared} sessions.`;
}
async function cmdLogs(args) {
const parts = (args || "").trim().split(/\s+/);
const n = parseInt(parts[0]) || 20;
const level = parts[1] || "error";
const d = await fetchJSON(`/logs?n=${n}&level=${level}`);
if (!d.entries?.length) return `No ${level} log entries.`;
return d.entries.map((e) => {
if (e.raw) return e.raw.slice(0, 120);
return `${(e.ts || "").slice(11, 19)} ${(e.level || "").toUpperCase()} ${e.event || "?"} ${e.model || ""}`;
}).join("\n");
}
function cmdHelp() {
return `OCP Commands
─────────────────────────────
/ocp usage Plan usage & model stats
/ocp status Quick overview
/ocp health Proxy diagnostics
/ocp settings View tunable settings
/ocp settings <k> <v> Update a setting
/ocp logs [N] [level] Recent logs (default: 20, error)
/ocp models Available models
/ocp sessions Active sessions
/ocp clear Clear all sessions`;
}
// ── Plugin entry point ──────────────────────────────────────────────────
export default function (api) {
console.log("[ocp] OCP plugin loading, registering /ocp command...");
api.registerCommand({
name: "ocp",
description: "OpenClaw Proxy commands — usage, health, settings, logs, etc.",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const raw = (ctx.args || "").trim();
const spaceIdx = raw.indexOf(" ");
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
try {
let text;
switch (subcmd) {
case "usage": text = await cmdUsage(); break;
case "health": text = await cmdHealth(); break;
case "status": text = await cmdStatus(); break;
case "settings": text = await cmdSettings(subargs || null); break;
case "models": text = await cmdModels(); break;
case "sessions": text = await cmdSessions(); break;
case "clear": text = await cmdClear(); break;
case "logs": text = await cmdLogs(subargs); break;
case "help": case "--help": case "-h": case "":
text = cmdHelp(); break;
default:
text = `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}`;
}
return { text: mono(text) };
} catch (err) {
return { text: `OCP error: ${err.message}` };
}
},
});
}
+17
View File
@@ -0,0 +1,17 @@
{
"id": "ocp",
"name": "OCP Commands",
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
"version": "1.0.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"proxyUrl": {
"type": "string",
"default": "http://127.0.0.1:3456",
"description": "URL of the Claude proxy"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "ocp",
"version": "1.0.0",
"description": "Slash commands for the OpenClaw Proxy",
"main": "index.js",
"type": "module",
"keywords": ["openclaw", "plugin", "ocp", "proxy"],
"license": "MIT",
"openclaw": {
"type": "plugin",
"id": "ocp",
"pluginManifest": "openclaw.plugin.json"
}
}
+182
View File
@@ -0,0 +1,182 @@
# openclaw-claude-proxy v2.3.0
Use your **Claude Pro / Max** subscription as an **OpenAI-compatible local endpoint**.
`openclaw-claude-proxy` accepts OpenAI-style chat completion requests, then runs them through the local `claude` CLI. That means tools which only know how to talk to an OpenAI API can still use Claude models through a local base URL.
## Why v2 matters
v2 is not just a bugfix release. It changes the runtime model:
- **On-demand spawning** instead of fragile warm pools
- **Session resume** support for multi-turn conversations
- **Faster fallback** with first-byte timeout + lower default request timeout
- **Full tool access** via configurable allowed tools
- **MCP config + system prompt pass-through**
- **Health / sessions / diagnostics endpoints**
- **Safe coexistence with Claude Code channel / interactive mode**
## The short pitch
If Claude's new channel workflow feels useful, OCP v2 now covers the same practical ground for many local agent/tooling setups:
- multi-turn continuity
- tool-enabled Claude runs
- local orchestration
- stable process isolation
- coexistence with your normal Claude Code workflow
And it adds a few advantages that channel users usually still want:
- **OpenAI-compatible HTTP API** for existing tools
- **Works with OpenClaw, Cursor, Continue, Open WebUI, LangChain, and anything with custom base URL support**
- **Explicit health checks and diagnostics**
- **Model/provider failover can happen outside Claude itself**
- **No lock-in to a single client UX**
## Coexistence with Claude Code channel
This is the important part: **OCP v2 does not replace Claude Code channel, and it does not need to. They can coexist on the same machine.**
### Claude Code channel / interactive mode
- persistent interactive workflow
- MCP protocol / in-process experience
- great when you are directly driving Claude Code
### OCP v2
- local HTTP server on `localhost`
- OpenAI-compatible API surface
- per-request `claude -p` execution with session resume when you want continuity
- ideal for external tools, routers, orchestrators, OpenClaw providers, and local automation
### Practical takeaway
Use both:
- use **Claude Code channel** when you want Claude's native interactive workflow
- use **OCP v2** when another app expects an OpenAI-style API but you still want to use Claude
They solve adjacent problems, not identical ones.
## Unique advantages of OCP v2
1. **API compatibility**
- Drop into tools that already support OpenAI-compatible endpoints.
- No need to wait for each tool to add native Claude channel support.
2. **Routing freedom**
- Put OCP behind OpenClaw or another router.
- Mix Claude with fallback providers outside the Claude client itself.
3. **Operational visibility**
- `/health`, `/sessions`, recent errors, auth state, resolved binary path, timeout config.
- Much easier to debug than a black-box local integration.
4. **Safer runtime model**
- v2 removes the old pre-spawn pool crash loop.
- No stale workers, no degraded warm pool states, fewer hidden failure modes.
5. **Configurable tools and behavior**
- allowed tools
- skip permissions mode
- system prompt append
- MCP config passthrough
- session TTL
- concurrency limits
## Install
```bash
git clone https://github.com/dtzp555-max/openclaw-claude-proxy
cd openclaw-claude-proxy
npm install
node server.mjs
```
Default base URL:
```text
http://127.0.0.1:3456/v1
```
## Quick OpenAI-compatible config
```json
{
"baseURL": "http://127.0.0.1:3456/v1",
"apiKey": "anything"
}
```
If `PROXY_API_KEY` is unset, auth is disabled. If you set it, pass it as a Bearer token.
## Environment variables
| Variable | Default | Purpose |
|---|---:|---|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | auto-detect | Claude CLI binary path |
| `CLAUDE_TIMEOUT` | `120000` | Overall per-request timeout |
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `30000` | Abort if Claude produces no stdout quickly |
| `CLAUDE_ALLOWED_TOOLS` | expanded set | Comma-separated allowed tools |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass permission checks |
| `CLAUDE_SYSTEM_PROMPT` | unset | Append a system prompt to every request |
| `CLAUDE_MCP_CONFIG` | unset | Path to MCP config JSON |
| `CLAUDE_SESSION_TTL` | `3600000` | Session TTL |
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent Claude processes |
| `PROXY_API_KEY` | unset | Optional Bearer token auth |
## Endpoints
- `GET /health`
- `GET /v1/models`
- `POST /v1/chat/completions`
- `GET /sessions`
- `DELETE /sessions`
## Example health response highlights
`/health` reports useful operational state such as:
- resolved Claude binary path
- whether the binary is executable
- auth status
- timeouts
- current sessions
- recent errors
- basic request stats
## Version highlights
### v2.3.0
- clarified v2 positioning and coexistence story in docs
- officially documents faster fallback defaults
- recommends OCP v2 as the API bridge layer for Claude-powered tools
### v2.2.0
- first-byte timeout
- reduced default timeout for faster fallback
### v2.0.0
- on-demand architecture
- session management
- full tool access
- MCP + system prompt passthrough
- concurrency control
- coexistence with Claude Code interactive mode
## When to use OCP v2 vs Claude channel
Choose **OCP v2** when:
- your app only supports OpenAI-compatible endpoints
- you want routing / failover outside Claude
- you want explicit health checks and local diagnostics
- you want Claude available to multiple local tools through one endpoint
Choose **Claude channel** when:
- you are primarily living inside Claude Code itself
- you want Claude's native interactive workflow directly
Use **both together** when you want the best of both worlds.
---
If you already pay for Claude Pro or Max, OCP v2 turns that subscription into a practical local API bridge for the rest of your tooling stack.
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "openclaw-claude-proxy",
"version": "1.8.0",
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
"version": "2.4.0",
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
"type": "module",
"bin": {
"openclaw-claude-proxy": "./server.mjs"
+392 -270
View File
@@ -1,21 +1,30 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* Features:
* - Process pool: pre-spawns CLI processes to eliminate cold start latency
* - SSE streaming + non-streaming responses
* - Concurrent request support
* 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:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: "claude")
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
* CLAUDE_POOL_SIZE — warm process pool size per model (default: 1)
* PROXY_API_KEY — Bearer token for API authentication (optional, if unset auth is disabled)
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: auto-detect)
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
* CLAUDE_BREAKER_THRESHOLD — consecutive timeouts before circuit opens (default: 3)
* CLAUDE_BREAKER_COOLDOWN — ms to wait before retrying after circuit opens (default: 60000)
* PROXY_API_KEY — Bearer token for API auth (optional)
*/
import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process";
@@ -64,26 +73,85 @@ function resolveClaude() {
process.exit(1);
}
// ── Configuration ───────────────────────────────────────────────────────
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
const POOL_SIZE = parseInt(process.env.CLAUDE_POOL_SIZE || "1", 10);
const POOL_MAX_IDLE = parseInt(process.env.CLAUDE_POOL_MAX_IDLE || "60000", 10); // max idle time before recycle
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "45000", 10);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
).split(",").map(s => s.trim()).filter(Boolean);
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "3", 10);
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "60000", 10);
const VERSION = _pkg.version;
const START_TIME = Date.now();
// Model alias mapping: request model → claude CLI --model arg
// Maps both shorthand aliases AND full model IDs to the canonical full model ID
// that the claude CLI accepts. Using short names like "sonnet"/"opus"/"haiku"
// causes the CLI to reject the --model arg and crash immediately.
// ── Structured logging helper ───────────────────────────────────────────
function logEvent(level, event, data = {}) {
const entry = { ts: new Date().toISOString(), level, event, ...data };
if (level === "error" || level === "warn") {
console.error(JSON.stringify(entry));
} else {
console.log(JSON.stringify(entry));
}
}
// ── Per-model circuit breaker ───────────────────────────────────────────
// Tracks consecutive timeouts per model. When threshold is reached, the
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
// window, requests for this model fail fast with a clear error instead of
// waiting for yet another timeout that would block the gateway.
const breakers = new Map(); // cliModel → { failures, state, openedAt }
function getBreakerState(cliModel) {
if (!breakers.has(cliModel)) {
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
}
const b = breakers.get(cliModel);
// Auto-recover: if cooldown has elapsed, transition to half-open
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
b.state = "half-open";
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
}
return b;
}
function breakerRecordSuccess(cliModel) {
const b = getBreakerState(cliModel);
if (b.failures > 0 || b.state !== "closed") {
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
}
b.failures = 0;
b.state = "closed";
b.openedAt = 0;
}
function breakerRecordTimeout(cliModel) {
const b = getBreakerState(cliModel);
b.failures++;
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
b.state = "open";
b.openedAt = Date.now();
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
}
}
// ── Model mapping ───────────────────────────────────────────────────────
// Maps request model IDs and aliases to canonical claude CLI model IDs.
const MODEL_MAP = {
// Full canonical IDs (pass through as-is)
"claude-opus-4-6": "claude-opus-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
// Short aliases → full canonical IDs
"claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "claude-haiku-4-5-20251001",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
@@ -98,270 +166,287 @@ const MODELS = [
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
];
// ── Process Pool ──────────────────────────────────────────────────────────
// Pre-spawns `claude -p` processes that read prompts from stdin.
// When a request arrives, we grab a warm process and pipe the prompt in.
// After the process finishes, a new one is spawned to replace it.
// ── Session management ──────────────────────────────────────────────────
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
// Enables --resume for multi-turn conversations, reducing token waste.
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
const pool = new Map(); // model → [{ proc, ready }]
// Exponential backoff state per model: tracks consecutive fast failures
// to prevent a tight spawn/die loop when workers crash on startup.
// Delays: 2s base, doubled each failure, capped at 60s.
// After 5 consecutive fast crashes (each lived < 10s, all within 60s),
// the model is marked "degraded" and respawning stops entirely.
const poolBackoff = new Map(); // model → { failures: number, timer: TimeoutId|null, degraded: boolean, windowStart: number }
function spawnWarm(cliModel) {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, [
"-p", "--model", cliModel,
"--output-format", "text",
"--no-session-persistence",
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
], { env, stdio: ["pipe", "pipe", "pipe"] });
const entry = { proc, cliModel, ready: true, spawnedAt: Date.now() };
// Capture stderr from pool workers so crash reasons are visible in logs
let stderrBuf = "";
proc.stderr.on("data", (d) => {
stderrBuf += d;
if (stderrBuf.length > 500) stderrBuf = stderrBuf.slice(-500);
});
proc.on("error", (err) => {
console.error(`[pool] spawn error model=${cliModel}: ${err.message}`);
entry.ready = false;
});
proc.on("exit", (code) => {
const livedMs = Date.now() - entry.spawnedAt;
entry.ready = false;
// Log stderr from crashed pool worker (first 500 chars) to aid debugging
if (stderrBuf.trim()) {
console.error(`[pool] worker stderr model=${cliModel} exit=${code} lived=${livedMs}ms: ${stderrBuf.slice(0, 500)}`);
}
// If the process survived > 10s, it was healthy — reset the backoff counter and window
if (livedMs > 10000) {
const state = poolBackoff.get(cliModel);
if (state && (state.failures > 0 || state.degraded)) {
console.log(`[pool] resetting backoff for model=${cliModel} (lived ${livedMs}ms)`);
state.failures = 0;
state.degraded = false;
state.windowStart = Date.now();
}
}
// Remove from pool
const arr = pool.get(cliModel);
if (arr) {
const idx = arr.indexOf(entry);
if (idx !== -1) arr.splice(idx, 1);
}
// Replenish: treat as crash (apply backoff) only if it died fast (< 10s)
const isCrash = livedMs <= 10000;
replenishPool(cliModel, isCrash);
});
return entry;
}
// Recycle idle processes to prevent stale connections
function recycleStaleProcesses() {
setInterval(() => {
const now = Date.now();
for (const [cliModel, arr] of pool) {
for (const entry of arr) {
if (entry.ready && (now - entry.spawnedAt) > POOL_MAX_IDLE) {
console.log(`[pool] recycling stale process model=${cliModel} (idle ${Math.round((now - entry.spawnedAt) / 1000)}s)`);
entry.ready = false;
entry.proc.kill();
// exit handler will replenish
}
for (const [id, s] of sessions) {
if (now - s.lastUsed > SESSION_TTL) {
sessions.delete(id);
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
}
}
}, 60000);
// ── Stats & diagnostics ─────────────────────────────────────────────────
const stats = {
totalRequests: 0,
activeRequests: 0,
errors: 0,
timeouts: 0,
sessionHits: 0,
sessionMisses: 0,
oneOffRequests: 0,
};
const recentErrors = []; // last 20 errors
function trackError(msg) {
stats.errors++;
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
if (recentErrors.length > 20) recentErrors.shift();
}
// ── Auth health check ───────────────────────────────────────────────────
let authStatus = { ok: null, lastCheck: 0, message: "" };
async function checkAuth() {
try {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
} catch (e) {
const msg = (e.stderr || e.message || "").slice(0, 200);
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
console.error(`[auth] check failed: ${msg}`);
}
}
setInterval(recycleStaleProcesses, 15000); // check every 15s
// Check auth on start and every 10 minutes
checkAuth();
setInterval(checkAuth, 600000);
const BACKOFF_BASE_MS = 2000; // 2s starting delay
const BACKOFF_MAX_MS = 60000; // 60s ceiling
const CRASH_LIMIT = 5; // max consecutive fast crashes before degraded
const CRASH_WINDOW_MS = 60000; // window for counting consecutive fast crashes (60s)
// ── Build CLI arguments ─────────────────────────────────────────────────
function buildCliArgs(cliModel, sessionInfo) {
const args = ["-p", "--model", cliModel, "--output-format", "text"];
// replenishPool(cliModel, isCrash)
// isCrash=false → initial or manual fill, no backoff applied
// isCrash=true → called from exit handler after a fast crash
function replenishPool(cliModel, isCrash = false) {
if (!pool.has(cliModel)) pool.set(cliModel, []);
if (!poolBackoff.has(cliModel)) poolBackoff.set(cliModel, { failures: 0, timer: null, degraded: false, windowStart: Date.now() });
const arr = pool.get(cliModel);
const state = poolBackoff.get(cliModel);
// If this model is degraded (too many consecutive fast crashes), stop respawning
if (state.degraded) {
console.error(`[pool] DEGRADED: model=${cliModel} will not be respawned. Restart the proxy to retry.`);
return;
// Session handling
if (sessionInfo?.resume) {
args.push("--resume", sessionInfo.uuid);
} else if (sessionInfo?.uuid) {
args.push("--session-id", sessionInfo.uuid);
} else {
args.push("--no-session-persistence");
}
const alive = arr.filter((e) => e.ready).length;
const needed = POOL_SIZE - alive;
if (needed <= 0) return;
// Cancel any pending backoff timer for this model before scheduling a new one
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
// Permissions
if (SKIP_PERMISSIONS) {
args.push("--dangerously-skip-permissions");
} else if (ALLOWED_TOOLS.length > 0) {
args.push("--allowedTools", ...ALLOWED_TOOLS);
}
// Only track failures and apply backoff when this is a crash respawn
if (!isCrash) {
// Immediate spawn — no backoff on initial fill or manual replenish
const currentAlive = arr.filter((e) => e.ready).length;
const currentNeeded = POOL_SIZE - currentAlive;
for (let i = 0; i < currentNeeded; i++) {
const entry = spawnWarm(cliModel);
arr.push(entry);
console.log(`[pool] pre-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length})`);
}
return;
// System prompt
if (SYSTEM_PROMPT) {
args.push("--append-system-prompt", SYSTEM_PROMPT);
}
// --- Crash path: apply exponential backoff and degraded-state logic ---
const now = Date.now();
// Reset window if the last crash was outside the rolling window
if ((now - state.windowStart) > CRASH_WINDOW_MS) {
state.windowStart = now;
state.failures = 0;
// MCP config
if (MCP_CONFIG) {
args.push("--mcp-config", MCP_CONFIG);
}
state.failures += 1;
// Check if we've hit the crash limit within the rolling window
if (state.failures >= CRASH_LIMIT) {
state.degraded = true;
console.error(
`[pool] DEGRADED: model=${cliModel} crashed ${state.failures} times in ` +
`${Math.round((now - state.windowStart) / 1000)}s. ` +
`Stopping respawn to prevent CPU spin. Restart the proxy to retry.`
);
return;
}
// Exponential backoff: 2s, 4s, 8s, 16s, 32s … capped at 60s
const delayMs = Math.min(BACKOFF_BASE_MS * Math.pow(2, state.failures - 1), BACKOFF_MAX_MS);
console.warn(`[pool] backoff model=${cliModel} delay=${delayMs}ms (failures=${state.failures}/${CRASH_LIMIT})`);
state.timer = setTimeout(() => {
state.timer = null;
const currentAlive = arr.filter((e) => e.ready).length;
const currentNeeded = POOL_SIZE - currentAlive;
for (let i = 0; i < currentNeeded; i++) {
const entry = spawnWarm(cliModel);
arr.push(entry);
console.log(`[pool] re-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length}, failures=${state.failures}/${CRASH_LIMIT})`);
}
}, delayMs);
return args;
}
function getWarmProcess(cliModel) {
const arr = pool.get(cliModel) || [];
const entry = arr.find((e) => e.ready);
if (entry) {
entry.ready = false; // mark as in-use
const warmMs = Date.now() - entry.spawnedAt;
console.log(`[pool] using warm process model=${cliModel} (warm for ${warmMs}ms)`);
return entry.proc;
}
return null;
// ── Format messages to prompt text ──────────────────────────────────────
function messagesToPrompt(messages) {
return messages.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
}).join("\n\n");
}
// Initialize pool for all models
function initPool() {
for (const cliModel of new Set(Object.values(MODEL_MAP))) {
replenishPool(cliModel);
}
// Model tier multipliers for first-byte timeout.
// Opus is much slower to produce first token, especially with large contexts.
const MODEL_TIMEOUT_TIERS = {
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
};
function getModelTier(cliModel) {
if (cliModel.includes("opus")) return "opus";
if (cliModel.includes("haiku")) return "haiku";
return "sonnet";
}
function computeFirstByteTimeout(cliModel, promptLength) {
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
}
// ── Call claude CLI ─────────────────────────────────────────────────────
function callClaude(model, messages) {
// On-demand spawning: each request spawns a fresh `claude -p` process.
// No pool = no crash loops, no stale workers, no degraded states.
// Stdin is written immediately so there's no 3s stdin timeout issue.
function callClaude(model, messages, conversationId) {
return new Promise((resolve, reject) => {
const prompt = messages
.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
})
.join("\n\n");
if (stats.activeRequests >= MAX_CONCURRENT) {
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
}
const cliModel = MODEL_MAP[model] || model;
// Try to use a warm process from the pool
let proc = getWarmProcess(cliModel);
let usedPool = !!proc;
if (!proc) {
// Cold start fallback: spawn fresh
console.log(`[pool] no warm process for model=${cliModel}, cold starting...`);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
proc = spawn(CLAUDE, [
"-p", "--model", cliModel,
"--output-format", "text",
"--no-session-persistence",
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
"--", prompt,
], { env, stdio: ["ignore", "pipe", "pipe"] });
// Circuit breaker check: fail fast if model is in open state
const breaker = getBreakerState(cliModel);
if (breaker.state === "open") {
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
}
stats.activeRequests++;
stats.totalRequests++;
let sessionInfo = null;
let prompt;
// ── Session logic ──
if (conversationId && sessions.has(conversationId)) {
// Resume existing session: only send the latest user message
const session = sessions.get(conversationId);
session.lastUsed = Date.now();
sessionInfo = { uuid: session.uuid, resume: true };
stats.sessionHits++;
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
prompt = lastUserMsg
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
: "";
session.messageCount = messages.length;
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
} else if (conversationId) {
// New session: send all messages, persist session for future --resume
const uuid = randomUUID();
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
sessionInfo = { uuid, resume: false };
stats.sessionMisses++;
prompt = messagesToPrompt(messages);
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
} else {
// One-off request, no session
stats.oneOffRequests++;
prompt = messagesToPrompt(messages);
}
const cliArgs = buildCliArgs(cliModel, sessionInfo);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const t0 = Date.now();
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
let settled = false;
let gotFirstByte = false;
proc.stdout.on("data", (d) => (stdout += d));
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code) => {
const elapsed = Date.now() - t0;
if (code !== 0) {
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 300)}`);
reject(new Error(stderr || stdout || `exit ${code}`));
function settle(err, result) {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(firstByteTimer);
stats.activeRequests--;
if (err) {
trackError(err.message || String(err));
// If session resume failed, remove session so next request starts fresh
if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
sessions.delete(conversationId);
}
reject(err);
} else {
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms pool=${usedPool}`);
resolve(stdout.trim());
resolve(result);
}
});
proc.on("error", reject);
// Log prompt size for debugging
console.log(`[claude] request model=${cliModel} prompt_chars=${prompt.length} pool=${usedPool}`);
// If using pool process, send prompt via stdin
if (usedPool) {
proc.stdin.write(prompt);
proc.stdin.end();
}
const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT);
proc.on("close", () => clearTimeout(timer));
proc.stdout.on("data", (d) => {
if (!gotFirstByte) {
gotFirstByte = true;
clearTimeout(firstByteTimer);
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
}
stdout += d;
});
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code, signal) => {
const elapsed = Date.now() - t0;
if (settled) {
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
return;
}
if (code !== 0) {
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
} else {
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
settle(null, stdout.trim());
}
});
proc.on("error", (err) => {
console.error(`[claude] spawn error: ${err.message}`);
settle(err);
});
// Write prompt to stdin immediately — no idle timeout issue
proc.stdin.write(prompt);
proc.stdin.end();
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// First-byte timeout: abort early if Claude CLI produces no output
const firstByteTimer = setTimeout(() => {
if (!gotFirstByte && !settled) {
stats.timeouts++;
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);
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
}
}, firstByteTimeoutMs);
// Overall request timeout with graceful kill
const timer = setTimeout(() => {
if (settled) return;
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
settle(new Error(`timeout after ${TIMEOUT}ms`));
}, TIMEOUT);
});
}
// ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
@@ -371,25 +456,23 @@ function sendSSE(res, data) {
}
function streamResponse(res, id, model, content) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const created = Math.floor(Date.now() / 1000);
// Role chunk
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
// Content chunks (~500 chars each)
for (let i = 0; i < content.length; i += 500) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
});
}
// Finish
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
@@ -420,10 +503,13 @@ async function handleChatCompletions(req, res) {
const model = parsed.model || "claude-sonnet-4-6";
const stream = parsed.stream;
// Session ID: from request body, header, or null (one-off)
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
try {
const content = await callClaude(model, messages);
const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`;
if (stream) {
@@ -444,8 +530,8 @@ async function handleChatCompletions(req, res) {
// ── HTTP server ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
@@ -473,49 +559,85 @@ const server = createServer(async (req, res) => {
return handleChatCompletions(req, res);
}
// GET /health — includes pool status, version, uptime
// GET /health — comprehensive diagnostics
if (req.url === "/health") {
const poolStatus = {};
for (const [model, arr] of pool) {
const readyCount = arr.filter(e => e.ready).length;
const errorCount = arr.filter(e => !e.ready).length;
poolStatus[model] = {
total: arr.length,
ready: readyCount,
error: errorCount,
status: readyCount > 0 ? "ready" : "error",
};
}
const uptimeMs = Date.now() - START_TIME;
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
const uptimeMs = Date.now() - START_TIME;
const sessionList = [];
for (const [id, s] of sessions) {
sessionList.push({
id: id.slice(0, 12) + "...",
model: s.model,
messages: s.messageCount,
idleMs: Date.now() - s.lastUsed,
});
}
return jsonResponse(res, 200, {
status: binaryOk ? "ok" : "degraded",
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
version: VERSION,
architecture: "on-demand (v2)",
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
pool: poolStatus,
auth: authStatus,
config: {
timeout: TIMEOUT,
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
maxConcurrent: MAX_CONCURRENT,
sessionTTL: SESSION_TTL,
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
mcpConfig: MCP_CONFIG || "(none)",
},
stats,
sessions: sessionList,
recentErrors: recentErrors.slice(-5),
});
}
// Catch-all: try to handle any POST with messages
// DELETE /sessions — clear all sessions
if (req.url === "/sessions" && req.method === "DELETE") {
const count = sessions.size;
sessions.clear();
return jsonResponse(res, 200, { cleared: count });
}
// GET /sessions — list active sessions
if (req.url === "/sessions" && req.method === "GET") {
const list = [];
for (const [id, s] of sessions) {
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
}
return jsonResponse(res, 200, { sessions: list });
}
// Catch-all POST
if (req.method === "POST") {
return handleChatCompletions(req, res);
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
});
// ── Start ──────────────────────────────────────────────────────────────
initPool();
// ── Start ──────────────────────────────────────────────────────────────
server.listen(PORT, "0.0.0.0", () => {
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms`);
console.log(`Pool size: ${POOL_SIZE} per model, max idle: ${POOL_MAX_IDLE / 1000}s`);
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
console.log(`---`);
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(` CC uses: MCP protocol (in-process) → persistent session`);
console.log(` Both can run simultaneously on the same machine.`);
});
+4 -3
View File
@@ -1,10 +1,11 @@
{
"name": "openclaw-claude-proxy",
"version": "2.0.0",
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
"version": "3.0.0",
"description": "OpenAI-compatible proxy for Claude CLI — plan usage monitoring, runtime settings, prompt truncation, OCP CLI",
"type": "module",
"bin": {
"openclaw-claude-proxy": "./server.mjs"
"openclaw-claude-proxy": "./server.mjs",
"ocp": "./ocp"
},
"scripts": {
"start": "node server.mjs",
+891 -162
View File
File diff suppressed because it is too large Load Diff