Compare commits

...
27 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.6 b6b33d2623 chore: add MIT LICENSE file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:37:11 +10:00
taodengandClaude Opus 4.6 8796a5fc19 docs: update README version to v3.3.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:49:54 +10:00
taodengandClaude Opus 4.6 609ceb28b0 fix: use stable node symlink path in macOS plist, avoid Cellar breakage
setup.mjs now resolves /opt/homebrew/Cellar/node/X.Y.Z/ to
/opt/homebrew/opt/node/ so the LaunchAgent plist survives node upgrades.
Bump to v3.3.1.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:07:29 +10:00
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
11 changed files with 1656 additions and 494 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tao Deng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+212 -246
View File
@@ -1,206 +1,240 @@
# openclaw-claude-proxy
# OCP — Open Claude Proxy
> **Already paying for Claude Pro/Max? Use it as your OpenClaw model provider — $0 extra API cost.**
> **Status: Stable (v3.3.1)** — Feature-complete. Bug fixes only.
A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run.
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
## v2.5.0 — Emergency Fix: Sliding-Window Circuit Breaker
**Incident (2026-03-22):** Multi-agent burst (ClawTeam with 5+ Opus agents) caused cascading timeout failure. The old consecutive-count circuit breaker (threshold=3) tripped within seconds, blocking ALL requests globally — including unrelated agents and new sessions. With fallback models removed, this resulted in complete service outage ("LLM request timed out." on every message).
**Root cause:** v2.4.0's circuit breaker counted consecutive failures per model. When ClawTeam spawned multiple concurrent Opus agents and Claude API had moderate latency, 3 quick timeouts opened the breaker for the entire model. With `fallbacks: []`, the gateway had no alternative path.
**What's new in v2.5.0:**
- **Sliding-window circuit breaker** — counts failures in a 5-minute window (default: 6 failures) instead of 3 consecutive. Multi-agent bursts no longer trip the breaker instantly.
- **Graduated backoff** — cooldown doubles on each re-open (120s → 240s → 300s cap), resets fully on first success. Prevents oscillation between open/half-open during extended API issues.
- **Multi-probe half-open** — allows 2 concurrent probe requests in half-open state (was 1), improving recovery speed.
- **Increased default timeouts** — Opus first-byte 60s→90s, Sonnet 45s→60s, overall 120s→300s, max concurrent 5→8. Designed for large agent system prompts (30K+ chars).
- **Health endpoint shows breaker state** — `/health` now exposes per-model breaker state (window failures, cooldown, reopen count). Status is "degraded" when any breaker is open.
**New env vars:**
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting |
| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state |
**Updated defaults:**
| Variable | Old Default | New Default |
|----------|-------------|-------------|
| `CLAUDE_TIMEOUT` | `120000` | `300000` |
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `45000` | `90000` |
| `CLAUDE_MAX_CONCURRENT` | `5` | `8` |
| `CLAUDE_BREAKER_THRESHOLD` | `3` | `6` |
| `CLAUDE_BREAKER_COOLDOWN` | `60000` | `120000` |
**Upgrade:** Pull latest and restart the proxy. The new defaults take effect immediately. If you have custom env vars set in your plist/service file, review and adjust them.
---
## v2.0.0 — Major Upgrade
**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.
**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
**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.
## How it works
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
```
OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via OAuth)
Cline ──┐
OpenCode ───┤
Aider ───┼──→ OCP :3456 ──→ Claude CLI ──→ Your subscription
Continue.dev ───┤
OpenClaw ───┘
```
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.
One proxy. Multiple IDEs. All models. **$0 API cost.**
## Prerequisites
## Supported Tools
- **Node.js** >= 18
- **Claude CLI** installed and authenticated (`claude login`)
- **OpenClaw** installed
Any tool that accepts `OPENAI_BASE_URL` works with OCP:
## Quick Start (Node.js)
| Tool | Configuration |
|------|--------------|
| **Cline** | Settings → `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
| **OpenCode** | `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
| **Aider** | `aider --openai-api-base http://127.0.0.1:3456/v1` |
| **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` |
| **OpenClaw** | `setup.mjs` auto-configures |
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
## Quick Start
```bash
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
cd openclaw-claude-proxy
# Auto-configure OpenClaw + start proxy + install auto-start
git clone https://github.com/dtzp555-max/ocp.git
cd ocp
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)
2. Start the proxy on port 3456
3. Install auto-start (launchd on macOS, systemd on Linux)
Then set your preferred Claude model as default:
```bash
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
openclaw gateway restart
```
## Session Management (v2.0)
Multi-turn conversations can use sessions to avoid resending full message history on every request.
**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.
```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?"}
]
}
```
**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.
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
**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:
Then point your IDE to the proxy:
```bash
node uninstall.mjs
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
```
## Manual Install
### 1. Start the proxy
### Verify
```bash
node server.mjs
# or in background:
bash start.sh
curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4
```
### 2. Configure OpenClaw
## Built-in Usage Monitoring
Add to `~/.openclaw/openclaw.json` under `models.providers`:
Check your subscription usage from the terminal:
```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
}
]
}
```
$ ocp usage
Plan Usage Limits
─────────────────────────────────────
Current session 21% used
Resets in 3h 12m (Tue, Mar 28, 10:00 PM)
Weekly (all models) 45% used
Resets in 4d 2h (Tue, Mar 31, 12:00 AM)
Extra usage off
Model Stats
Model Req OK Er AvgT MaxT AvgP MaxP
──────────────────────────────────────────────────────
opus 5 5 0 32s 87s 42K 43K
sonnet 18 18 0 20s 45s 36K 56K
Total 23
Proxy: up 6h 32m | 23 reqs | 0 err | 0 timeout
```
### 3. Set as default model
### All Commands
```
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
ocp update Update to latest version
ocp update --check Check for updates without applying
ocp --help Command reference
```
### Install the CLI
```bash
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
openclaw gateway restart
# Symlink to PATH (recommended)
sudo ln -sf $(pwd)/ocp /usr/local/bin/ocp
# Verify
ocp --help
```
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
### Self-Update
```bash
# Check if a new version is available
ocp update --check
# Pull latest, sync plugin, restart proxy — one command
ocp update
```
### Runtime Settings (No Restart Needed)
```
$ ocp settings maxPromptChars 200000
✓ maxPromptChars = 200000
$ ocp settings maxConcurrent 4
✓ maxConcurrent = 4
```
## How It Works
```
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
```
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
## Available Models
| Model ID | Claude CLI model | Notes |
|----------|-----------------|-------|
| `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
| `claude-haiku-4` | claude-haiku-4-5-20251001 | Fastest, lightweight |
| Model ID | Notes |
|----------|-------|
| `claude-opus-4-6` | Most capable, slower |
| `claude-sonnet-4-6` | Good balance of speed/quality |
| `claude-haiku-4` | Fastest, lightweight |
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
| `/health` | GET | Comprehensive health check |
| `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) |
| `/settings` | GET/PATCH | View or update settings at runtime |
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
| `/sessions` | GET/DELETE | List or clear active sessions |
## OpenClaw Integration
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
- **`setup.mjs`** auto-configures the `claude-local` provider in `openclaw.json`
- **Gateway plugin** registers `/ocp` as a native slash command in Telegram/Discord
- **Multi-agent** — 8 concurrent requests sharing one subscription
- **No conflicts** — uses neutral service names (`dev.ocp.proxy` / `ocp-proxy`) that don't trigger OpenClaw's gateway-like service detection
### Install the Gateway Plugin
```bash
cp -r ocp-plugin/ ~/.openclaw/extensions/ocp/
```
Add to `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"allow": ["ocp"],
"entries": { "ocp": { "enabled": true } }
}
}
```
Restart: `openclaw gateway restart`
## Troubleshooting
### Requests fail with exit 143 / SIGTERM after ~60 seconds
**Symptom:** Claude returns errors or stops responding after about 60 seconds, especially during tool use (Bash, Read, etc.).
**Cause:** OpenClaw's gateway has a default `idleTimeoutSeconds` of 60 seconds. When Claude calls tools, the token stream pauses while the tool executes — if that takes longer than 60s, the gateway kills the connection.
**Fix:** `setup.mjs` (v3.2.1+) sets this automatically. If you installed an older version, add this to `~/.openclaw/openclaw.json`:
```json
{
"agents": {
"defaults": {
"llm": {
"idleTimeoutSeconds": 0
}
}
}
}
```
Then restart: `openclaw gateway restart`
### Agents stuck in "typing" but never respond
Usually caused by stuck sessions from previous timeout errors. Fix:
```bash
# Clear all sessions
ocp clear
# Restart both services
ocp restart
openclaw gateway restart
```
If that doesn't help, manually clear the session store:
```bash
# Find and reset stuck Telegram sessions
cat ~/.openclaw/agents/main/sessions/sessions.json
# Remove entries with "telegram" channel, then restart gateway
```
## Upgrading from v3.0.x
If you installed OCP before v3.1.0, the auto-start service used names that OpenClaw's gateway detected as conflicting (`ai.openclaw.proxy` on macOS, `openclaw-proxy` on Linux). Running `node setup.mjs` or `ocp update` will automatically migrate to the new neutral names.
## Environment Variables
@@ -208,88 +242,20 @@ openclaw gateway restart
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms), adaptive by model tier |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Set `true` to 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_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
| `CLAUDE_BREAKER_THRESHOLD` | `6` | Failures in window before circuit opens |
| `CLAUDE_BREAKER_COOLDOWN` | `120000` | Base cooldown (ms) before half-open (graduates on re-open) |
| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting |
| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
## API Endpoints
## Security
- `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
## Authentication
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
```
## Architecture: v1 vs v2
| | 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 |
## Coexistence with Claude Code
OCP and Claude Code interactive mode (including Telegram bots) are completely independent:
| | OCP (this proxy) | CC interactive |
|---|---|---|
| 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 |
| Use case | Automated agent work | Human-in-the-loop |
Both can 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
```bash
cd ~/.openclaw/projects/claude-proxy # or wherever you cloned it
git pull # pull latest version
node setup.mjs # reconfigure OpenClaw + start proxy
openclaw gateway restart
```
## Notes
- 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)
- **Localhost only** — binds to `127.0.0.1`, not exposed to the network
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth
- **No API keys needed** — authentication goes through Claude CLI's OAuth session
- **Auto-start** — launchd (macOS) / systemd (Linux)
## License
Executable
+498
View File
@@ -0,0 +1,498 @@
#!/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..."
# Try current service name, then legacy, then manual restart
local uid
uid=$(id -u)
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
true
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
true
elif systemctl --user restart ocp-proxy 2>/dev/null; then
true
elif systemctl --user restart openclaw-proxy 2>/dev/null; then
true
else
echo "Service restart failed, trying kill + restart..."
pkill -f "server.mjs.*CLAUDE_PROXY" 2>/dev/null || pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
sleep 1
local self_r script_dir
self_r="${BASH_SOURCE[0]}"
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
fi
sleep 3
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
echo "✓ Proxy restarted successfully."
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
}
# ── update ──────────────────────────────────────────────────────────────
cmd_update_help() {
cat <<'EOF'
ocp update — Update OCP to the latest version
Pulls the latest code from GitHub, restarts the proxy service,
and optionally syncs the plugin to the OpenClaw extensions directory.
Usage:
ocp update Pull latest and restart
ocp update --check Check for updates without applying
EOF
}
cmd_update() {
local script_dir self
self="${BASH_SOURCE[0]}"
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
script_dir="$(cd "$(dirname "$self")" && pwd)"
# Check-only mode
if [[ "${1:-}" == "--check" ]]; then
cd "$script_dir"
git fetch origin main --quiet 2>/dev/null || true
local local_ver remote_ver
local_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
remote_ver=$(git show origin/main:package.json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "?")
local behind
behind=$(git rev-list HEAD..origin/main --count 2>/dev/null || echo "?")
echo "OCP Update Check"
echo "─────────────────────────────────────"
echo " Current: v$local_ver"
echo " Latest: v$remote_ver"
if [[ "$behind" == "0" ]]; then
echo " Status: ✓ Up to date"
else
echo " Status: $behind commit(s) behind"
echo ""
echo " Run 'ocp update' to apply."
fi
return 0
fi
echo "Updating OCP..."
echo ""
# 1. Pull latest
cd "$script_dir"
local old_ver
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
echo " Pulling latest from GitHub..."
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
return 1
fi
local new_ver
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
if [[ "$old_ver" == "$new_ver" ]]; then
echo " ✓ Already at latest (v$new_ver)"
else
echo " ✓ Updated v$old_ver → v$new_ver"
fi
# 2. Sync plugin to extensions dir
local ext_dir="$HOME/.openclaw/extensions/ocp"
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
echo ""
echo " Syncing OCP plugin..."
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
echo " ✓ Plugin synced to $ext_dir"
fi
# 3. Restart proxy
echo ""
echo " Restarting proxy..."
cmd_restart > /dev/null 2>&1
sleep 2
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
local running_ver
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
echo " ✓ Proxy running (v$running_ver)"
else
echo " ⚠ Proxy not responding — check: ocp health"
fi
echo ""
echo "Done."
}
# ── help ─────────────────────────────────────────────────────────────────
cmd_help() {
cat <<'EOF'
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
update Update OCP to latest version
update --check Check for updates
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:-}" ;;
update) cmd_update "${1:-}" ;;
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
esac
+301
View File
@@ -0,0 +1,301 @@
/**
* 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 plan = d.plan || {};
const s = plan.currentSession || {};
const w = plan.weeklyLimits?.allModels || {};
const e = plan.extraUsage || {};
const px = d.proxy;
const models = d.models || {};
let out = "";
// Show subscription info if available
if (plan.subscription) {
out += `Plan: ${plan.subscription} (${plan.rateLimitTier || "default"})\n`;
}
if (s.utilization !== null && s.utilization !== undefined) {
// Full plan usage data available (API key mode)
out += "Plan Usage Limits\n";
out += "─────────────────────────────\n";
out += `Current session ${bar(s.utilization)} ${s.percent}\n`;
out += ` Resets in ${s.resetsIn} (${s.resetsAtHuman})\n\n`;
out += `Weekly (all) ${bar(w.utilization)} ${w.percent}\n`;
out += ` Resets in ${w.resetsIn} (${w.resetsAtHuman})\n\n`;
out += `Extra usage ${e.status === "allowed" ? "on" : "off"}\n\n`;
} else {
// No API key — show note
out += "Session/weekly %: claude.ai/settings\n";
out += "(Anthropic OAuth API doesn't expose limits yet)\n\n";
}
const modelNames = Object.keys(models).sort();
if (modelNames.length > 0) {
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 cmdVersion() {
const d = await fetchJSON("/health");
return `OCP v${d.version || "?"}\nUptime: ${d.uptimeHuman || "?"}\nNode: ${process.version}\nPlatform: ${process.platform} ${process.arch}`;
}
async function cmdTest() {
const t0 = Date.now();
try {
const resp = await fetch(`${PROXY}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "claude-haiku-4-5-20251001",
messages: [{ role: "user", content: "say ok" }],
max_tokens: 5,
}),
signal: AbortSignal.timeout(30000),
});
const d = await resp.json();
const elapsed = Date.now() - t0;
if (d.choices?.[0]?.message?.content) {
return `✓ Proxy OK (${elapsed}ms)\n Model: haiku\n Response: "${d.choices[0].message.content.slice(0, 50)}"`;
}
return `✗ Unexpected response: ${JSON.stringify(d).slice(0, 100)}`;
} catch (e) {
return `✗ Test failed (${Date.now() - t0}ms): ${e.message}`;
}
}
async function cmdRestart(args) {
const target = (args || "").trim().toLowerCase();
const { execSync } = await import("node:child_process");
try {
if (target === "gateway") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
return "✓ Gateway restarted";
} else if (target === "all") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
// Gateway restart will kill this plugin too, so do it last
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
return "✓ Proxy + Gateway restarted";
} else {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e) {
// Try systemd for Linux
try {
if (target === "gateway") {
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
return "✓ Gateway restarted";
} else {
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
return "✓ Proxy restarted";
}
} catch (e2) {
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
}
}
}
async function cmdLogs(args) {
const parts = (args || "").trim().split(/\s+/);
const n = parseInt(parts[0]) || 20;
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
/ocp restart Restart proxy
/ocp restart gateway Restart gateway
/ocp restart all Restart both
/ocp version Version & platform info
/ocp test End-to-end proxy test`;
}
// ── Plugin entry point ──────────────────────────────────────────────────
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 "restart": text = await cmdRestart(subargs); break;
case "version": text = await cmdVersion(); break;
case "test": text = await cmdTest(); break;
case "logs": text = await cmdLogs(subargs); break;
case "help": case "--help": case "-h": case "":
text = cmdHelp(); break;
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": "3.3.1",
"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": "3.3.1",
"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"
}
}
+5 -4
View File
@@ -1,10 +1,11 @@
{
"name": "openclaw-claude-proxy",
"version": "2.5.0",
"description": "OpenAI-compatible proxy for Claude CLI v2 — sliding-window circuit breaker, adaptive first-byte timeout, structured logging",
"version": "3.3.1",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module",
"bin": {
"openclaw-claude-proxy": "./server.mjs"
"openclaw-claude-proxy": "./server.mjs",
"ocp": "./ocp"
},
"scripts": {
"start": "node server.mjs",
@@ -23,6 +24,6 @@
},
"repository": {
"type": "git",
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
"url": "https://github.com/dtzp555-max/ocp"
}
}
+487 -205
View File
@@ -1,30 +1,19 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy v2.5.0 — OpenAI-compatible proxy for Claude CLI
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* v2.5.0:
* - Sliding-window circuit breaker: uses time-windowed failure rate instead of
* consecutive-count, preventing multi-agent burst scenarios from tripping the
* breaker too aggressively. Half-open state allows configurable probe requests.
* - Graduated backoff: cooldown doubles on each re-open (capped at 5min),
* resets fully on success.
* - Health endpoint now exposes per-model breaker state and sliding window stats.
* - Increased default timeout tiers for Opus/Sonnet to handle large agent prompts.
*
* v2.4.0:
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
* - Adaptive first-byte timeout: scales by model tier + prompt size
* - Structured JSON logging for key events (easier to parse/alert on)
* - On-demand spawning (no pool), session management, full tool access
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
* No separate first-byte or idle timeout — Claude tool-use causes long pauses
* in the token stream (30s-5min) that make fine-grained timeouts unreliable.
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
*
* Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: auto-detect)
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 300000)
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 90000)
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
@@ -43,6 +32,7 @@ import { randomUUID, timingSafeEqual } from "node:crypto";
import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -85,10 +75,10 @@ function resolveClaude() {
}
// ── Configuration ───────────────────────────────────────────────────────
// Settings marked with `let` can be changed at runtime via PATCH /settings.
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10);
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
@@ -96,8 +86,8 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
).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 || "8", 10);
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10);
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
@@ -116,129 +106,28 @@ function logEvent(level, event, data = {}) {
}
}
// ── Per-model sliding-window circuit breaker ─────────────────────────────
// Uses a time-windowed failure rate instead of consecutive-count. This prevents
// multi-agent burst scenarios (e.g. ClawTeam spawning 5+ Opus agents) from
// tripping the breaker after just 3 quick timeouts.
//
// States: closed → open → half-open → closed (on success) or open (on failure)
// Half-open allows up to BREAKER_HALF_OPEN_MAX concurrent probes (not just 1).
// Cooldown uses graduated backoff: doubles on each re-open, resets on success.
const breakers = new Map(); // cliModel → BreakerState
// ── Circuit breaker (DISABLED) ──────────────────────────────────────────
// Disabled: CLI proxy has its own retry logic, and the breaker was causing
// cascading failures — once API got briefly slow, ALL agents lost connectivity
// for 120s+ due to the breaker rejecting every request.
// The timeout/failure tracking stubs below are kept as no-ops so callers
// don't need to be changed.
function breakerRecordSuccess(_cliModel) {}
function breakerRecordTimeout(_cliModel) {}
function getBreakerState(_cliModel) { return { state: "closed" }; }
function getBreakerSnapshot() { return { _note: "circuit breaker disabled" }; }
function newBreakerState() {
return {
state: "closed", // closed | open | half-open
failureTimestamps: [], // timestamps of failures within the sliding window
successCount: 0, // successes within window (for rate calculation)
openedAt: 0,
currentCooldown: BREAKER_COOLDOWN, // graduates on repeated opens
reopenCount: 0, // how many times breaker has re-opened without a full reset
halfOpenProbes: 0, // active probe requests in half-open state
};
}
// Legacy constants kept for /health display
const _BREAKER_DISABLED_NOTE = "disabled";
/* Original breaker code removed — see git history for v2.5.0 implementation.
Re-enable by reverting this block if needed in the future.
Reason for disabling: CLI-proxy architecture means each request spawns a
fresh claude process. The breaker was designed for persistent API connections
where a degraded backend benefits from back-off. With CLI spawning, timeouts
are usually transient (API load, large prompts) and the breaker's 120s+
cooldown with graduated backoff made things worse, not better.
*/
function pruneWindow(b) {
const cutoff = Date.now() - BREAKER_WINDOW;
b.failureTimestamps = b.failureTimestamps.filter(ts => ts > cutoff);
}
function getBreakerState(cliModel) {
if (!breakers.has(cliModel)) {
breakers.set(cliModel, newBreakerState());
}
const b = breakers.get(cliModel);
// Auto-recover: if cooldown has elapsed, transition to half-open
if (b.state === "open" && Date.now() - b.openedAt >= b.currentCooldown) {
b.state = "half-open";
b.halfOpenProbes = 0;
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: b.currentCooldown, reopenCount: b.reopenCount });
}
return b;
}
function breakerRecordSuccess(cliModel) {
const b = getBreakerState(cliModel);
b.successCount++;
if (b.state === "half-open") {
b.halfOpenProbes = Math.max(0, b.halfOpenProbes - 1);
}
if (b.state !== "closed") {
logEvent("info", "breaker_reset", {
model: cliModel,
previousFailures: b.failureTimestamps.length,
previousState: b.state,
reopenCount: b.reopenCount,
});
// Full reset on success — graduated backoff resets too
b.state = "closed";
b.openedAt = 0;
b.currentCooldown = BREAKER_COOLDOWN;
b.reopenCount = 0;
b.halfOpenProbes = 0;
b.failureTimestamps = [];
b.successCount = 0;
}
}
function breakerRecordTimeout(cliModel) {
const b = getBreakerState(cliModel);
const now = Date.now();
b.failureTimestamps.push(now);
pruneWindow(b);
if (b.state === "half-open") {
b.halfOpenProbes = Math.max(0, b.halfOpenProbes - 1);
}
const windowFailures = b.failureTimestamps.length;
logEvent("warn", "breaker_failure", {
model: cliModel,
windowFailures,
threshold: BREAKER_THRESHOLD,
windowMs: BREAKER_WINDOW,
state: b.state,
});
if (windowFailures >= BREAKER_THRESHOLD && b.state !== "open") {
b.state = "open";
b.openedAt = now;
b.halfOpenProbes = 0;
// Graduated backoff: double cooldown on each re-open, cap at 5 min
if (b.reopenCount > 0) {
b.currentCooldown = Math.min(b.currentCooldown * 2, 300000);
}
b.reopenCount++;
logEvent("error", "breaker_open", {
model: cliModel,
windowFailures,
cooldownMs: b.currentCooldown,
reopenCount: b.reopenCount,
});
}
}
// Expose breaker snapshot for /health endpoint
function getBreakerSnapshot() {
const snapshot = {};
for (const [model, b] of breakers) {
pruneWindow(b);
snapshot[model] = {
state: b.state,
windowFailures: b.failureTimestamps.length,
threshold: BREAKER_THRESHOLD,
windowMs: BREAKER_WINDOW,
currentCooldown: b.currentCooldown,
reopenCount: b.reopenCount,
halfOpenProbes: b.halfOpenProbes,
...(b.openedAt ? { openedAt: new Date(b.openedAt).toISOString() } : {}),
};
}
return snapshot;
}
// ── Model mapping ───────────────────────────────────────────────────────
// Maps request model IDs and aliases to canonical claude CLI model IDs.
@@ -290,6 +179,57 @@ const stats = {
};
const recentErrors = []; // last 20 errors
// Per-model request stats
const modelStats = new Map(); // cliModel → { requests, errors, timeouts, totalElapsed, maxElapsed, totalPromptChars, maxPromptChars }
function getModelStats(cliModel) {
if (!modelStats.has(cliModel)) {
modelStats.set(cliModel, {
requests: 0, successes: 0, errors: 0, timeouts: 0,
totalElapsed: 0, maxElapsed: 0,
totalPromptChars: 0, maxPromptChars: 0,
});
}
return modelStats.get(cliModel);
}
function recordModelRequest(cliModel, promptChars) {
const m = getModelStats(cliModel);
m.requests++;
m.totalPromptChars += promptChars;
if (promptChars > m.maxPromptChars) m.maxPromptChars = promptChars;
}
function recordModelSuccess(cliModel, elapsedMs) {
const m = getModelStats(cliModel);
m.successes++;
m.totalElapsed += elapsedMs;
if (elapsedMs > m.maxElapsed) m.maxElapsed = elapsedMs;
}
function recordModelError(cliModel, isTimeout) {
const m = getModelStats(cliModel);
m.errors++;
if (isTimeout) m.timeouts++;
}
function getModelStatsSnapshot() {
const result = {};
for (const [model, m] of modelStats) {
result[model] = {
requests: m.requests,
successes: m.successes,
errors: m.errors,
timeouts: m.timeouts,
avgElapsed: m.successes > 0 ? Math.round(m.totalElapsed / m.successes) : 0,
maxElapsed: m.maxElapsed,
avgPromptChars: m.requests > 0 ? Math.round(m.totalPromptChars / m.requests) : 0,
maxPromptChars: m.maxPromptChars,
};
}
return result;
}
function trackError(msg) {
stats.errors++;
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
@@ -353,35 +293,66 @@ function buildCliArgs(cliModel, sessionInfo) {
}
// ── Format messages to prompt text ──────────────────────────────────────
// Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system
// message(s) + first user message + last N messages, dropping the middle.
// This prevents runaway context from gateway-side conversation accumulation.
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
function messagesToPrompt(messages) {
return messages.map((m) => {
const full = 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");
});
const joined = full.join("\n\n");
if (joined.length <= MAX_PROMPT_CHARS) return joined;
// Truncation: keep system messages, first user msg, and trim from the tail
logEvent("warn", "prompt_truncated", {
originalChars: joined.length,
maxChars: MAX_PROMPT_CHARS,
originalMessages: messages.length,
});
const system = [];
const rest = [];
for (let i = 0; i < full.length; i++) {
if (messages[i].role === "system") system.push(full[i]);
else rest.push(full[i]);
}
// 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: 90000, perPromptChar: 0.00020 }, // 90s base + ~20s per 100k chars
"sonnet": { base: 60000, perPromptChar: 0.00010 }, // 60s base + ~10s per 100k chars
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
};
// Keep system + as many recent messages as fit
const systemText = system.join("\n\n");
const budget = MAX_PROMPT_CHARS - systemText.length - 200; // 200 for separator
const kept = [];
let used = 0;
for (let i = rest.length - 1; i >= 0; i--) {
if (used + rest[i].length + 2 > budget) break;
kept.unshift(rest[i]);
used += rest[i].length + 2;
}
const truncNote = `[System] Note: ${rest.length - kept.length} older messages were truncated to fit context limit.`;
const result = [systemText, truncNote, ...kept].filter(Boolean).join("\n\n");
logEvent("info", "prompt_after_truncation", {
chars: result.length,
keptMessages: kept.length,
droppedMessages: rest.length - kept.length,
});
return result;
}
// Model tier — used for logging only (no timeout logic).
function getModelTier(cliModel) {
if (cliModel.includes("opus")) return "opus";
if (cliModel.includes("haiku")) return "haiku";
return "sonnet";
}
function computeFirstByteTimeout(cliModel, promptLength) {
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
}
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
// Resolves session logic, builds CLI args, spawns the process, and sets up
// timeouts. Returns context object or throws synchronously.
@@ -392,22 +363,7 @@ function spawnClaudeProcess(model, messages, conversationId) {
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.currentCooldown - (Date.now() - breaker.openedAt);
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs, reopenCount: breaker.reopenCount });
throw new Error(`circuit breaker open for ${cliModel}: ${breaker.failureTimestamps.length} timeouts in window, retry in ${Math.ceil(remainingMs / 1000)}s`);
}
// Half-open: allow limited probe requests
if (breaker.state === "half-open" && breaker.halfOpenProbes >= BREAKER_HALF_OPEN_MAX) {
logEvent("warn", "breaker_half_open_full", { model: cliModel, activeProbes: breaker.halfOpenProbes, max: BREAKER_HALF_OPEN_MAX });
throw new Error(`circuit breaker half-open for ${cliModel}: ${breaker.halfOpenProbes}/${BREAKER_HALF_OPEN_MAX} probe slots in use, wait for probe result`);
}
if (breaker.state === "half-open") {
breaker.halfOpenProbes++;
logEvent("info", "breaker_probe", { model: cliModel, activeProbes: breaker.halfOpenProbes, max: BREAKER_HALF_OPEN_MAX });
}
// Circuit breaker: disabled (see comment at top of breaker section)
stats.activeRequests++;
stats.totalRequests++;
@@ -456,7 +412,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
activeProcesses.add(proc);
const t0 = Date.now();
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
let gotFirstByte = false;
let cleaned = false;
@@ -464,7 +419,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
if (cleaned) return;
cleaned = true;
clearTimeout(overallTimer);
clearTimeout(firstByteTimer);
stats.activeRequests--;
}
@@ -478,7 +432,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
function markFirstByte() {
if (!gotFirstByte) {
gotFirstByte = true;
clearTimeout(firstByteTimer);
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
}
}
@@ -487,25 +440,18 @@ function spawnClaudeProcess(model, messages, conversationId) {
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" });
recordModelRequest(cliModel, prompt.length);
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// First-byte timeout
const firstByteTimer = setTimeout(() => {
if (!gotFirstByte && !cleaned) {
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
}
}, firstByteTimeoutMs);
// Overall request timeout
// Single request timeout — no separate first-byte timer.
// Claude tool-use causes long pauses in the token stream (30s-5min),
// making first-byte/idle timeouts unreliable. One generous timeout is simpler and correct.
const overallTimer = setTimeout(() => {
if (!cleaned) {
stats.timeouts++;
recordModelError(cliModel, true);
breakerRecordTimeout(cliModel);
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT, elapsed: Date.now() - t0 });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
}
@@ -542,11 +488,13 @@ function callClaude(model, messages, conversationId) {
const elapsed = Date.now() - t0;
cleanup();
if (code !== 0) {
recordModelError(cliModel, false);
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`);
handleSessionFailure();
reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
} else {
recordModelSuccess(cliModel, elapsed);
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
resolve(stdout.trim());
@@ -620,15 +568,14 @@ function callClaudeStreaming(model, messages, conversationId, res) {
const elapsed = Date.now() - t0;
if (code !== 0) {
recordModelError(cliModel, false);
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
handleSessionFailure();
if (!headersSent && !res.writableEnded && !res.destroyed) {
// No output was sent yet — return a JSON error
jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } });
} else if (!res.writableEnded && !res.destroyed) {
// Already streaming — close the stream gracefully
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
@@ -637,6 +584,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
res.end();
}
} else {
recordModelSuccess(cliModel, elapsed);
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
@@ -693,6 +641,326 @@ function completionResponse(res, id, model, content) {
});
}
// ── Plan usage probe ────────────────────────────────────────────────────
// Reads the OAuth token from macOS keychain and makes a minimal API call
// to Anthropic to capture rate-limit headers (plan usage info).
// Caches the result for 5 minutes to avoid excessive API calls.
let usageCache = { data: null, fetchedAt: 0 };
const USAGE_CACHE_TTL = 300000; // 5 min
function getOAuthToken() {
// Try Linux file-based credentials first
try {
const credPath = join(homedir(), ".claude", ".credentials.json");
const creds = JSON.parse(readFileSync(credPath, "utf8"));
const token = creds?.claudeAiOauth?.accessToken;
if (token) return token;
} catch { /* fall through to macOS keychain */ }
// Try macOS keychain
try {
const raw = execFileSync("security", [
"find-generic-password", "-s", "Claude Code-credentials", "-w"
], { encoding: "utf8", timeout: 5000 }).trim();
const creds = JSON.parse(raw);
return creds?.claudeAiOauth?.accessToken || null;
} catch {
return null;
}
}
async function fetchUsageFromApi() {
const token = getOAuthToken();
if (!token) {
return { error: "No OAuth token found in keychain" };
}
// Minimal API call to haiku (cheapest) with max_tokens=1 — we only need the headers
const body = JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 1,
messages: [{ role: "user", content: "." }],
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const resp = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": token,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
body,
signal: controller.signal,
});
clearTimeout(timeout);
// Extract all rate-limit headers
const rl = {};
for (const [k, v] of resp.headers) {
if (k.startsWith("anthropic-ratelimit")) {
rl[k] = v;
}
}
// Parse into structured usage object
const now = Date.now();
const session5hUtil = parseFloat(rl["anthropic-ratelimit-unified-5h-utilization"] || "0");
const session5hReset = parseInt(rl["anthropic-ratelimit-unified-5h-reset"] || "0", 10);
const weekly7dUtil = parseFloat(rl["anthropic-ratelimit-unified-7d-utilization"] || "0");
const weekly7dReset = parseInt(rl["anthropic-ratelimit-unified-7d-reset"] || "0", 10);
const overageStatus = rl["anthropic-ratelimit-unified-overage-status"] || "unknown";
const overageDisabledReason = rl["anthropic-ratelimit-unified-overage-disabled-reason"] || "";
const status = rl["anthropic-ratelimit-unified-status"] || "unknown";
const representativeClaim = rl["anthropic-ratelimit-unified-representative-claim"] || "";
const fallbackPct = parseFloat(rl["anthropic-ratelimit-unified-fallback-percentage"] || "0");
function formatReset(epochSec) {
if (!epochSec) return "unknown";
const diff = epochSec * 1000 - now;
if (diff <= 0) return "now";
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
if (h > 24) {
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h`;
}
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function resetDay(epochSec) {
if (!epochSec) return "";
const d = new Date(epochSec * 1000);
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
return {
status,
fetchedAt: new Date(now).toISOString(),
plan: {
currentSession: {
utilization: session5hUtil,
percent: `${Math.round(session5hUtil * 100)}%`,
resetsIn: formatReset(session5hReset),
resetsAt: session5hReset ? new Date(session5hReset * 1000).toISOString() : null,
resetsAtHuman: resetDay(session5hReset),
},
weeklyLimits: {
allModels: {
utilization: weekly7dUtil,
percent: `${Math.round(weekly7dUtil * 100)}%`,
resetsIn: formatReset(weekly7dReset),
resetsAt: weekly7dReset ? new Date(weekly7dReset * 1000).toISOString() : null,
resetsAtHuman: resetDay(weekly7dReset),
},
},
extraUsage: {
status: overageStatus,
disabledReason: overageDisabledReason || undefined,
},
representativeClaim,
fallbackPercentage: fallbackPct,
},
proxy: {
totalRequests: stats.totalRequests,
activeRequests: stats.activeRequests,
errors: stats.errors,
timeouts: stats.timeouts,
uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`,
},
models: getModelStatsSnapshot(),
_raw: rl,
};
} catch (err) {
clearTimeout(timeout);
return { error: `Failed to fetch usage: ${err.message}` };
}
}
async function handleUsage(_req, res) {
const now = Date.now();
let data;
if (usageCache.data && (now - usageCache.fetchedAt) < USAGE_CACHE_TTL) {
data = usageCache.data;
} else {
data = await fetchUsageFromApi();
if (!data.error) {
usageCache = { data, fetchedAt: now };
}
}
// Always attach live model stats and proxy stats (not cached)
const uptimeMs = now - START_TIME;
const response = {
...data,
proxy: {
totalRequests: stats.totalRequests,
activeRequests: stats.activeRequests,
errors: stats.errors,
timeouts: stats.timeouts,
uptime: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
},
models: getModelStatsSnapshot(),
};
jsonResponse(res, data.error ? 502 : 200, response);
}
// ── Logs endpoint ──────────────────────────────────────────────────────
// Returns recent structured log entries from the proxy log file.
// GET /logs?n=20&level=error (default: n=30, level=all)
function handleLogs(req, res) {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const n = Math.min(parseInt(url.searchParams.get("n") || "30", 10), 200);
const level = url.searchParams.get("level") || "all"; // all | error | warn | info
const LOG_PATH = join(process.env.HOME || "/tmp", ".openclaw/logs/proxy.log");
let lines;
try {
const raw = readFileSync(LOG_PATH, "utf8");
lines = raw.split("\n").filter(Boolean);
} catch (err) {
return jsonResponse(res, 500, { error: `Cannot read log: ${err.message}` });
}
// Parse JSON lines, fall back to raw text
let entries = lines.slice(-n * 3).map(line => {
try { return JSON.parse(line); } catch { return { raw: line }; }
});
// Filter by level
if (level !== "all") {
entries = entries.filter(e => {
if (e.level) return e.level === level;
if (level === "error") return e.raw?.includes("error") || e.raw?.includes("Error");
return true;
});
}
entries = entries.slice(-n);
return jsonResponse(res, 200, {
count: entries.length,
level,
entries,
});
}
// ── Status endpoint (combined summary) ─────────────────────────────────
async function handleStatus(_req, res) {
const now = Date.now();
const uptimeMs = now - START_TIME;
// Get usage (from cache if fresh)
let usage = null;
if (usageCache.data && (now - usageCache.fetchedAt) < USAGE_CACHE_TTL) {
usage = usageCache.data;
} else {
usage = await fetchUsageFromApi();
if (!usage.error) usageCache = { data: usage, fetchedAt: now };
}
// Auth
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
return jsonResponse(res, 200, {
proxy: {
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
version: VERSION,
uptime: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
auth: authStatus.ok ? "ok" : authStatus.message,
activeSessions: sessions.size,
},
requests: {
total: stats.totalRequests,
active: stats.activeRequests,
errors: stats.errors,
timeouts: stats.timeouts,
},
plan: usage?.plan || usage?.error || null,
recentErrors: recentErrors.slice(-3),
});
}
// ── Settings endpoint ───────────────────────────────────────────────────
// GET /settings → view current tunable parameters
// PATCH /settings → update one or more parameters at runtime
//
// Tunable keys and their types/ranges:
const SETTINGS_SCHEMA = {
timeout: { type: "number", min: 30000, max: 1800000, unit: "ms", desc: "Request timeout (default: 600s)" },
maxConcurrent: { type: "number", min: 1, max: 32, unit: "", desc: "Max concurrent claude processes" },
sessionTTL: { type: "number", min: 60000, max: 86400000, unit: "ms", desc: "Session idle expiry" },
maxPromptChars: { type: "number", min: 10000, max: 1000000, unit: "chars", desc: "Prompt truncation limit" },
};
function getSettings() {
return {
timeout: { value: TIMEOUT, ...SETTINGS_SCHEMA.timeout },
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars },
};
}
function applySettingUpdate(key, value) {
const schema = SETTINGS_SCHEMA[key];
if (!schema) return `unknown setting: ${key}`;
if (typeof value !== schema.type) return `${key}: expected ${schema.type}, got ${typeof value}`;
if (value < schema.min || value > schema.max) return `${key}: value ${value} out of range [${schema.min}, ${schema.max}]`;
switch (key) {
case "timeout": TIMEOUT = value; break;
case "maxConcurrent": MAX_CONCURRENT = value; break;
case "sessionTTL": SESSION_TTL = value; break;
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
default: return `${key}: not implemented`;
}
logEvent("info", "setting_changed", { key, value });
return null; // success
}
async function handleSettings(req, res) {
if (req.method === "GET") {
return jsonResponse(res, 200, getSettings());
}
// PATCH
let body = "";
for await (const chunk of req) {
body += chunk;
if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" });
}
let updates;
try { updates = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
if (typeof updates !== "object" || Array.isArray(updates)) {
return jsonResponse(res, 400, { error: "Expected JSON object with key-value pairs" });
}
const results = {};
const errors = [];
for (const [key, value] of Object.entries(updates)) {
const err = applySettingUpdate(key, value);
if (err) {
errors.push(err);
results[key] = { error: err };
} else {
results[key] = { ok: true, value };
}
}
const status = errors.length === 0 ? 200 : (Object.keys(results).length > errors.length ? 207 : 400);
return jsonResponse(res, status, {
results,
...(errors.length ? { errors } : {}),
current: getSettings(),
});
}
// ── Handle chat completions ─────────────────────────────────────────────
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB
@@ -799,11 +1067,8 @@ const server = createServer(async (req, res) => {
});
}
const breakerState = getBreakerSnapshot();
const anyBreakerOpen = Object.values(breakerState).some(b => b.state === "open");
return jsonResponse(res, 200, {
status: binaryOk && authStatus.ok !== false && !anyBreakerOpen ? "ok" : "degraded",
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
version: VERSION,
architecture: "on-demand (v2)",
uptime: uptimeMs,
@@ -813,19 +1078,15 @@ const server = createServer(async (req, res) => {
auth: authStatus,
config: {
timeout: TIMEOUT,
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
maxConcurrent: MAX_CONCURRENT,
sessionTTL: SESSION_TTL,
breakerThreshold: BREAKER_THRESHOLD,
breakerCooldown: BREAKER_COOLDOWN,
breakerWindow: BREAKER_WINDOW,
breakerHalfOpenMax: BREAKER_HALF_OPEN_MAX,
circuitBreaker: "disabled",
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
mcpConfig: MCP_CONFIG || "(none)",
},
stats,
breakers: breakerState,
circuitBreaker: "disabled",
sessions: sessionList,
recentErrors: recentErrors.slice(-5),
});
@@ -847,7 +1108,28 @@ const server = createServer(async (req, res) => {
return jsonResponse(res, 200, { sessions: list });
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
// GET /usage — fetch plan usage limits from Anthropic API
if (req.url === "/usage" && req.method === "GET") {
return handleUsage(req, res);
}
// GET /logs — recent proxy log entries (errors and key events)
if (req.url?.startsWith("/logs") && req.method === "GET") {
return handleLogs(req, res);
}
// GET /status — combined usage + health summary
if (req.url === "/status" && req.method === "GET") {
return handleStatus(req, res);
}
// GET /settings — view current tunable settings
// PATCH /settings — update settings at runtime (JSON body)
if (req.url === "/settings" && (req.method === "GET" || req.method === "PATCH")) {
return handleSettings(req, res);
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions" });
});
@@ -909,8 +1191,8 @@ server.listen(PORT, "127.0.0.1", () => {
console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Circuit breaker: threshold=${BREAKER_THRESHOLD} in ${BREAKER_WINDOW/1000}s window, cooldown=${BREAKER_COOLDOWN/1000}s (graduated), half-open probes=${BREAKER_HALF_OPEN_MAX}`);
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Circuit breaker: disabled`);
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
+64 -14
View File
@@ -12,7 +12,7 @@
* 4. Creates start.sh for easy launch
* 5. Optionally starts the proxy
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
@@ -169,6 +169,19 @@ for (const [key, val] of Object.entries(MODEL_ALIASES)) {
}
log(`Model aliases added to agents.defaults.models`);
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
config.agents.defaults.llm.idleTimeoutSeconds = 0;
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
} else {
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
}
writeJSON(CONFIG_PATH, config);
log(`Config saved`);
@@ -285,26 +298,62 @@ if (!DRY_RUN) {
console.log("\n🔄 Installing auto-start on login...\n");
const platform = process.platform;
const nodeBin = process.execPath;
// Use stable symlink path instead of versioned Cellar path (e.g. /opt/homebrew/opt/node/bin/node
// instead of /opt/homebrew/Cellar/node/25.8.0/bin/node) so the plist survives node upgrades.
let nodeBin = process.execPath;
if (platform === "darwin" && nodeBin.includes("/Cellar/")) {
const stable = nodeBin.replace(/\/Cellar\/[^/]+\/[^/]+\//, "/opt/");
if (existsSync(stable)) {
nodeBin = stable;
log(`Using stable node path: ${nodeBin}`);
}
}
// Ensure logs dir exists
const logsDir = join(OPENCLAW_DIR, "logs");
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
// Use neutral service names to avoid OpenClaw gateway's extra-service detection.
// OpenClaw scans LaunchAgent plists and systemd units for "openclaw" / "clawdbot"
// markers and flags them as conflicting gateway-like services. Using "dev.ocp.*"
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
const OCP_HOME = join(HOME, ".ocp");
const ocpLogsDir = join(OCP_HOME, "logs");
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true });
// Uninstall legacy service names if present (upgrade path)
if (platform === "darwin") {
const legacyPlist = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
if (existsSync(legacyPlist)) {
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPlist}" 2>/dev/null`); } catch { /* ignore */ }
try { unlinkSync(legacyPlist); } catch { /* ignore */ }
log(`Removed legacy plist: ai.openclaw.proxy`);
}
} else if (platform === "linux") {
const legacyService = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
if (existsSync(legacyService)) {
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
try { unlinkSync(legacyService); } catch { /* ignore */ }
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
log(`Removed legacy systemd service: openclaw-proxy`);
}
}
if (platform === "darwin") {
// macOS: launchd
const plistDir = join(HOME, "Library", "LaunchAgents");
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
const plistPath = join(plistDir, "ai.openclaw.proxy.plist");
const logPath = join(logsDir, "proxy.log");
const plistPath = join(plistDir, "dev.ocp.proxy.plist");
const logPath = join(ocpLogsDir, "proxy.log");
const plistXml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.openclaw.proxy</string>
<string>dev.ocp.proxy</string>
<key>ProgramArguments</key>
<array>
<string>${nodeBin}</string>
@@ -330,27 +379,28 @@ if (!DRY_RUN) {
writeFileSync(plistPath, plistXml);
log(`Plist written: ${plistPath}`);
// Unload first (in case it was already loaded) then load
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
execSync(`launchctl load "${plistPath}"`);
log(`launchctl loaded ai.openclaw.proxy`);
// Bootout first (in case it was already loaded) then bootstrap
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
execSync(`launchctl bootstrap gui/$(id -u) "${plistPath}"`);
log(`launchctl loaded dev.ocp.proxy`);
} else if (platform === "linux") {
// Linux: systemd user service
const systemdDir = join(HOME, ".config", "systemd", "user");
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
const servicePath = join(systemdDir, "openclaw-proxy.service");
const logPath = join(logsDir, "proxy.log");
const servicePath = join(systemdDir, "ocp-proxy.service");
const logPath = join(ocpLogsDir, "proxy.log");
const serviceUnit = `[Unit]
Description=OpenClaw Claude Proxy
Description=OCP — Open Claude Proxy
After=network.target
[Service]
ExecStart=${nodeBin} ${serverPath}
Environment=CLAUDE_PROXY_PORT=${PORT}
Restart=always
RestartSec=5
StandardOutput=append:${logPath}
StandardError=append:${logPath}
@@ -362,8 +412,8 @@ WantedBy=default.target
log(`Service file written: ${servicePath}`);
execSync(`systemctl --user daemon-reload`);
execSync(`systemctl --user enable openclaw-proxy`);
execSync(`systemctl --user start openclaw-proxy`);
execSync(`systemctl --user enable ocp-proxy`);
execSync(`systemctl --user start ocp-proxy`);
log(`systemd user service enabled and started`);
} else {
+1 -1
View File
@@ -3,7 +3,7 @@
PORT=${CLAUDE_PROXY_PORT:-3456}
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/openclaw-claude-proxy/server.mjs" \
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/server.mjs" \
>> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
echo "claude-proxy started on port $PORT (pid $!)"
+35 -23
View File
@@ -3,6 +3,9 @@
* openclaw-claude-proxy uninstaller
*
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
* Handles both legacy (ai.openclaw.proxy / openclaw-proxy) and current
* (dev.ocp.proxy / ocp-proxy) service names.
*
* Run: node uninstall.mjs
*/
import { existsSync, unlinkSync } from "node:fs";
@@ -15,43 +18,52 @@ const HOME = homedir();
function log(msg) { console.log(`${msg}`); }
function warn(msg) { console.log(`${msg}`); }
console.log("\n🗑 Uninstalling openclaw-claude-proxy auto-start...\n");
console.log("\n🗑 Uninstalling OCP auto-start...\n");
const platform = process.platform;
if (platform === "darwin") {
const plistPath = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
// Remove current service
const plistPath = join(HOME, "Library", "LaunchAgents", "dev.ocp.proxy.plist");
if (existsSync(plistPath)) {
try {
execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
log("launchd service stopped and unloaded");
} catch {
warn("launchctl unload failed (service may not have been running)");
}
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
unlinkSync(plistPath);
log(`Plist removed: ${plistPath}`);
} else {
warn(`Plist not found: ${plistPath}`);
log(`Removed: ${plistPath}`);
}
// Remove legacy service
const legacyPath = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
if (existsSync(legacyPath)) {
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPath}" 2>/dev/null`); } catch { /* ignore */ }
unlinkSync(legacyPath);
log(`Removed legacy: ${legacyPath}`);
}
if (!existsSync(plistPath) && !existsSync(legacyPath)) {
warn("No plist found (service may not have been installed)");
}
} else if (platform === "linux") {
const servicePath = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
log("systemd service stopped");
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
log("systemd service disabled");
// Remove current service
const servicePath = join(HOME, ".config", "systemd", "user", "ocp-proxy.service");
try { execSync(`systemctl --user stop ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
try { execSync(`systemctl --user disable ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
if (existsSync(servicePath)) {
unlinkSync(servicePath);
log(`Service file removed: ${servicePath}`);
} else {
warn(`Service file not found: ${servicePath}`);
log(`Removed: ${servicePath}`);
}
// Remove legacy service
const legacyPath = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
if (existsSync(legacyPath)) {
unlinkSync(legacyPath);
log(`Removed legacy: ${legacyPath}`);
}
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
log("systemd daemon reloaded");
} else {
warn(`Auto-start not supported on ${platform} — nothing to remove`);