mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e39d7820 | ||
|
|
7434f6adc0 | ||
|
|
8a7951134e | ||
|
|
47434a8ba7 | ||
|
|
1d95dbeebc | ||
|
|
9e6bef729b |
@@ -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.**
|
> **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:**
|
### `/ocp` — Your Proxy Command Center
|
||||||
- **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.
|
|
||||||
|
|
||||||
**Coexistence with Claude Code interactive mode:**
|
Full management interface available from Telegram, Discord, or any terminal.
|
||||||
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
|
|
||||||
|
|
||||||
**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)
|
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.
|
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
|
## Quick Start
|
||||||
|
|
||||||
- **Node.js** >= 18
|
|
||||||
- **Claude CLI** installed and authenticated (`claude login`)
|
|
||||||
- **OpenClaw** installed
|
|
||||||
|
|
||||||
## Quick Start (Node.js)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
||||||
@@ -50,119 +120,69 @@ cd openclaw-claude-proxy
|
|||||||
node setup.mjs
|
node setup.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. The setup script will:
|
The setup script will:
|
||||||
1. Verify Claude CLI is installed and authenticated
|
1. Verify Claude CLI is installed and authenticated
|
||||||
2. Add `claude-local` provider to `openclaw.json`
|
2. Add `claude-local` provider to `openclaw.json`
|
||||||
3. Add auth profiles to all agents
|
3. Start the proxy and install auto-start (launchd on macOS, systemd on Linux)
|
||||||
4. Start the proxy
|
|
||||||
5. Install auto-start on login (launchd on macOS, systemd on Linux)
|
|
||||||
|
|
||||||
Then set your preferred Claude model as default:
|
Then set your preferred model:
|
||||||
```bash
|
```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
|
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
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-6",
|
"plugins": {
|
||||||
"session_id": "conv-abc-123",
|
"allow": ["ocp"],
|
||||||
"messages": [
|
"entries": { "ocp": { "enabled": true } }
|
||||||
{"role": "user", "content": "Hello"},
|
}
|
||||||
{"role": "assistant", "content": "Hi there!"},
|
|
||||||
{"role": "user", "content": "What did I just say?"}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**First request** with a new session_id: all messages are sent, session is persisted via `--session-id`.
|
Restart the gateway: `openclaw gateway restart`
|
||||||
**Subsequent requests** with the same session_id: only the latest user message is sent via `--resume`, reducing token consumption.
|
|
||||||
|
|
||||||
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
|
## API Endpoints
|
||||||
|
|
||||||
**API endpoints:**
|
| Endpoint | Method | Description |
|
||||||
- `GET /sessions` — list all active sessions
|
|----------|--------|-------------|
|
||||||
- `DELETE /sessions` — clear all sessions
|
| `/v1/models` | GET | List available models |
|
||||||
|
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||||
## Security
|
| `/health` | GET | Comprehensive health check |
|
||||||
|
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||||
- **Localhost only** — the proxy binds to `127.0.0.1` and is not exposed to the internet or your local network
|
| `/status` | GET | Combined overview (usage + health) |
|
||||||
- **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
|
| `/settings` | GET | View tunable settings |
|
||||||
- **No API keys for Claude** — authentication to Anthropic goes through Claude CLI's OAuth session, no Anthropic credentials are stored in the proxy
|
| `/settings` | PATCH | Update settings at runtime |
|
||||||
- **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
|
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
|
||||||
- **Remove auto-start** at any time:
|
| `/sessions` | GET | List active sessions |
|
||||||
|
| `/sessions` | DELETE | Clear all sessions |
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Models
|
## 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-opus-4-6` | claude-opus-4-6 | Most capable, slower |
|
||||||
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
|
| `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_PROXY_PORT` | `3456` | Listen port |
|
||||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
| `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_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_SYSTEM_PROMPT` | *(empty)* | System prompt appended to all requests |
|
||||||
| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON file |
|
| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON |
|
||||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry in ms (default: 1 hour) |
|
|
||||||
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent claude processes |
|
|
||||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
||||||
|
|
||||||
## API Endpoints
|
## Session Management
|
||||||
|
|
||||||
- `GET /v1/models` — List available models
|
Multi-turn conversations use `--resume` to avoid resending full history on every request.
|
||||||
- `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
|
|
||||||
|
|
||||||
## 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.
|
```json
|
||||||
|
{
|
||||||
**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.
|
"model": "claude-sonnet-4-6",
|
||||||
|
"session_id": "conv-abc-123",
|
||||||
**When `PROXY_API_KEY` is not set**, authentication is disabled and all requests are accepted.
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello"},
|
||||||
```bash
|
{"role": "assistant", "content": "Hi!"},
|
||||||
# Start with auth enabled
|
{"role": "user", "content": "What did I just say?"}
|
||||||
PROXY_API_KEY=my-secret-token node server.mjs
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 |
|
| Process lifecycle | Pre-spawn idle workers | Spawn per request |
|
||||||
| Crash handling | Backoff → DEGRADED → manual restart | No crash loops (no idle workers) |
|
| Crash handling | Backoff spiral | No crash loops |
|
||||||
| Session support | None (stateless) | --resume with session tracking |
|
| Session support | None | `--resume` with tracking |
|
||||||
| Tool access | 6 tools hardcoded | Configurable, expanded defaults |
|
| Tool access | 6 hardcoded | Configurable, expanded |
|
||||||
| System prompt | None | CLAUDE_SYSTEM_PROMPT env |
|
| Prompt guard | None | Truncation at 150K chars |
|
||||||
| MCP support | None | CLAUDE_MCP_CONFIG env |
|
| Monitoring | Basic `/health` | `/usage`, `/status`, `/settings`, `/logs` |
|
||||||
| Concurrency | Unlimited (dangerous) | CLAUDE_MAX_CONCURRENT limit |
|
| CLI | None | `ocp` command |
|
||||||
| Auth monitoring | None | Periodic health checks |
|
| Gateway plugin | None | `/ocp` slash command |
|
||||||
| Diagnostics | Basic /health | Full stats, sessions, errors |
|
|
||||||
|
|
||||||
## Coexistence with Claude Code
|
## 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) |
|
| Protocol | HTTP (localhost:3456) | MCP (in-process) |
|
||||||
| Process model | Per-request spawn | Persistent session |
|
| Process model | Per-request spawn | Persistent session |
|
||||||
| Lifecycle | Daemon (auto-start, auto-recover) | Requires terminal |
|
| Lifecycle | Daemon (auto-start) | Requires terminal |
|
||||||
| Permission model | Pre-approved tools | Interactive prompts |
|
|
||||||
| Use case | Automated agent work | Human-in-the-loop |
|
| 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
|
## 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/.openclaw/projects/claude-proxy # or wherever you cloned it
|
cd ~/.openclaw/projects/claude-proxy
|
||||||
git pull # pull latest version
|
git pull
|
||||||
node setup.mjs # reconfigure OpenClaw + start proxy
|
node setup.mjs
|
||||||
openclaw gateway restart
|
openclaw gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Changelog
|
||||||
|
|
||||||
- Cost shows as $0 because billing goes through your Claude subscription
|
### v3.0.0 (2026-03-24)
|
||||||
- Each request spawns a `claude -p` process; concurrent requests are capped by `CLAUDE_MAX_CONCURRENT`
|
- **`/ocp` CLI** — full management from terminal (`ocp usage`, `ocp settings`, etc.)
|
||||||
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
|
- **`/ocp` gateway plugin** — native slash command in Telegram/Discord
|
||||||
- Session data is stored by Claude CLI on disk; session map is in-memory (lost on proxy restart)
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"description": "OpenAI-compatible proxy for Claude CLI v2 — on-demand sessions, faster fallback, and safe coexistence with Claude Code channels",
|
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openclaw-claude-proxy": "./server.mjs"
|
"openclaw-claude-proxy": "./server.mjs"
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* openclaw-claude-proxy v2.3.0 — 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,
|
* 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.0.0 highlights:
|
* v2.4.0:
|
||||||
* - On-demand spawning: eliminates pool crash loops from v1.x
|
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||||
* - Session management: --resume support reduces token waste on multi-turn
|
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||||
* - Full tool access: configurable allowedTools (expanded defaults)
|
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||||
* - System prompt & MCP config pass-through
|
* - On-demand spawning (no pool), session management, full tool access
|
||||||
* - Concurrency control with queuing
|
|
||||||
* - Coexists safely with Claude Code interactive mode (Telegram, IDE, etc.)
|
|
||||||
*
|
*
|
||||||
* 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: 120000)
|
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
||||||
* CLAUDE_FIRST_BYTE_TIMEOUT — abort if no stdout within this ms (default: 30000)
|
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
|
||||||
* 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
|
||||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
|
* 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)
|
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||||
*/
|
*/
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
@@ -77,7 +77,7 @@ function resolveClaude() {
|
|||||||
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();
|
||||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
||||||
const FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "30000", 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 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 ||
|
||||||
@@ -87,10 +87,65 @@ const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
|||||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||||
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 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 VERSION = _pkg.version;
|
||||||
const START_TIME = Date.now();
|
const START_TIME = Date.now();
|
||||||
|
|
||||||
|
// ── 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 ───────────────────────────────────────────────────────
|
// ── Model mapping ───────────────────────────────────────────────────────
|
||||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||||
const MODEL_MAP = {
|
const MODEL_MAP = {
|
||||||
@@ -210,6 +265,26 @@ function messagesToPrompt(messages) {
|
|||||||
}).join("\n\n");
|
}).join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ─────────────────────────────────────────────────────
|
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
@@ -219,10 +294,20 @@ function callClaude(model, messages, conversationId) {
|
|||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
|
||||||
|
// 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.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
|
|
||||||
const cliModel = MODEL_MAP[model] || model;
|
|
||||||
let sessionInfo = null;
|
let sessionInfo = null;
|
||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
@@ -271,6 +356,7 @@ function callClaude(model, messages, conversationId) {
|
|||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let gotFirstByte = false;
|
let gotFirstByte = false;
|
||||||
|
|
||||||
@@ -306,13 +392,18 @@ function callClaude(model, messages, conversationId) {
|
|||||||
});
|
});
|
||||||
proc.stderr.on("data", (d) => (stderr += d));
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
|
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code, signal) => {
|
||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
|
if (settled) {
|
||||||
|
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 500)}`);
|
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}`));
|
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||||
} else {
|
} else {
|
||||||
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
breakerRecordSuccess(cliModel);
|
||||||
|
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||||
settle(null, stdout.trim());
|
settle(null, stdout.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -326,24 +417,27 @@ function callClaude(model, messages, conversationId) {
|
|||||||
proc.stdin.write(prompt);
|
proc.stdin.write(prompt);
|
||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
|
|
||||||
console.log(`[claude] spawned model=${cliModel} prompt_chars=${prompt.length} session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
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
|
// First-byte timeout: abort early if Claude CLI produces no output
|
||||||
const firstByteTimer = setTimeout(() => {
|
const firstByteTimer = setTimeout(() => {
|
||||||
if (!gotFirstByte) {
|
if (!gotFirstByte && !settled) {
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
console.error(`[claude] first-byte timeout after ${FIRST_BYTE_TIMEOUT}ms model=${cliModel} — aborting`);
|
breakerRecordTimeout(cliModel);
|
||||||
proc.kill("SIGTERM");
|
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||||
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||||
settle(new Error(`first-byte timeout after ${FIRST_BYTE_TIMEOUT}ms`));
|
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
|
||||||
}
|
}
|
||||||
}, FIRST_BYTE_TIMEOUT);
|
}, firstByteTimeoutMs);
|
||||||
|
|
||||||
// Overall request timeout with graceful kill
|
// Overall request timeout with graceful kill
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
console.error(`[claude] timeout after ${TIMEOUT}ms model=${cliModel}`);
|
breakerRecordTimeout(cliModel);
|
||||||
proc.kill("SIGTERM");
|
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||||
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||||
}, TIMEOUT);
|
}, TIMEOUT);
|
||||||
@@ -492,7 +586,7 @@ const server = createServer(async (req, res) => {
|
|||||||
auth: authStatus,
|
auth: authStatus,
|
||||||
config: {
|
config: {
|
||||||
timeout: TIMEOUT,
|
timeout: TIMEOUT,
|
||||||
firstByteTimeout: FIRST_BYTE_TIMEOUT,
|
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||||
maxConcurrent: MAX_CONCURRENT,
|
maxConcurrent: MAX_CONCURRENT,
|
||||||
sessionTTL: SESSION_TTL,
|
sessionTTL: SESSION_TTL,
|
||||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||||
@@ -535,7 +629,7 @@ server.listen(PORT, "0.0.0.0", () => {
|
|||||||
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 (first-byte: ${FIRST_BYTE_TIMEOUT}ms) | Max concurrent: ${MAX_CONCURRENT}`);
|
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(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||||
|
|||||||
+4
-3
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "2.2.0",
|
"version": "3.0.0",
|
||||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
|
"description": "OpenAI-compatible proxy for Claude CLI — plan usage monitoring, runtime settings, prompt truncation, OCP CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openclaw-claude-proxy": "./server.mjs"
|
"openclaw-claude-proxy": "./server.mjs",
|
||||||
|
"ocp": "./ocp"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
|
|||||||
+886
-180
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user