Compare commits

..
24 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.6 7434f6adc0 docs: v2.5.0 release notes — sliding-window circuit breaker incident writeup
Document the 2026-03-22 multi-agent cascading timeout incident, root cause
analysis, and all new/changed defaults. Update env vars table.

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

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

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

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

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

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

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

Bumps version to 2.4.1.

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

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

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

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

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

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

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

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

---------

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:53:27 +10:00
taodengandClaude Opus 4.6 cedd2acecc feat: v2.0.0 — on-demand spawning, session management, full tool access
Major architectural upgrade:
- Replace pool system with on-demand spawning (eliminates crash loops,
  DEGRADED states, and stdin timeout errors from v1.x)
- Add session management with --resume support (reduces token waste on
  multi-turn conversations)
- Expand default allowed tools (Bash, Read, Write, Edit, Glob, Grep,
  WebSearch, WebFetch, Agent) with configurable CLAUDE_ALLOWED_TOOLS
- Add system prompt pass-through (CLAUDE_SYSTEM_PROMPT)
- Add MCP config support (CLAUDE_MCP_CONFIG)
- Add concurrency control (CLAUDE_MAX_CONCURRENT, default 5)
- Add periodic auth health monitoring
- Add session API endpoints (GET/DELETE /sessions)
- Improve /health with full diagnostics (stats, sessions, errors, config)
- Fix timeout race condition (graceful SIGTERM → SIGKILL)
- Fix ERR_HTTP_HEADERS_SENT by checking headersSent in all response helpers
- Document coexistence with Claude Code interactive mode (Telegram, IDE)
- No conflict with CC: different ports, protocols, and process models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:32:40 +10:00
taodengandClaude Opus 4.6 9a22372e28 feat: auto-detect claude binary to prevent ENOENT in launchd (v1.8.0)
The proxy previously defaulted to bare "claude" command which fails in
macOS LaunchAgent where PATH is minimal. Now resolves the binary at
startup via: CLAUDE_BIN env → well-known paths → which fallback.
Fails fast with clear error if binary cannot be found.

Also enhances /health to report claudeBinary and claudeBinaryOk status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:35:18 +10:00
taodeng 917ff60014 fix: harden proxy recovery and sanitize anthropic env (v1.7.1) 2026-03-20 12:29:48 +10:00
taodeng 5f00d6960b docs: add Bearer token authentication section and update env vars for v1.7.0 2026-03-19 20:15:20 +10:00
taodeng 2fa7991d6e feat: add Bearer token authentication via PROXY_API_KEY (v1.7.0)
- Add optional PROXY_API_KEY env var for Bearer token auth
- Return 401 for missing/invalid token on all endpoints except /health
- Skip auth when PROXY_API_KEY is not set (backwards compatible)
- Log auth status on startup
2026-03-19 17:45:01 +10:00
taodengandClaude Sonnet 4.6 ff05657c94 fix: add crash backoff and stderr logging to pool manager
- Rework replenishPool to accept isCrash flag: initial fills spawn
  immediately with no delay; only crash-triggered respawns apply backoff
- Exponential backoff on crash: 2s, 4s, 8s, 16s, 32s, capped at 60s
- After 5 consecutive fast crashes (< 10s lived) within a 60s rolling
  window, mark model as "degraded" and stop all respawning to prevent
  CPU spin loops
- Degraded state resets automatically if a subsequent spawn lives > 10s
- stderr from crashed pool workers is captured and logged on exit
- Bump version to 1.6.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:27:40 +10:00
taodengandClaude Sonnet 4.6 17384acb4c fix: add pool respawn backoff + stderr capture + model name mapping (v1.5.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:24:49 +10:00
taodengandClaude Sonnet 4.6 37803edb3f fix: read version from package.json instead of hardcoded string
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:45 +10:00
taodengandClaude Sonnet 4.6 43d4599dac feat: add auto-start on boot and restructure README (v1.5.0)
- setup.mjs: install launchd plist (macOS) or systemd user service (Linux) after proxy starts; logs to ~/.openclaw/logs/proxy.log
- Add uninstall.mjs to stop and remove the auto-start entry
- README: Quick Start (Node.js) first, Security section, Docker moved to Server/Advanced
- Bump version to 1.5.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:17:15 +10:00
taodengandClaude Sonnet 4.6 6ff48f68c0 feat: add Docker support and improve /health endpoint
- Add Dockerfile (node:20-alpine, EXPOSE 3456, ENV for session tokens)
- Add docker-compose.yml with restart: unless-stopped
- Add .dockerignore
- Improve /health: add version, uptime, uptimeHuman, per-pool ready/error status
- Listen on 0.0.0.0 for container compatibility
- Bump version to 1.4.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:11:13 +10:00
taodengandClaude Opus 4.6 857c703b0a fix: restore --allowedTools flag broken in v1.3.0 pool refactor
The v1.3.0 process pool refactor changed --allowedTools to --tools ""
which disabled all CLI tools. This caused agents to output raw <tool_use>
XML as text instead of executing tools. Restored --allowedTools with
Bash, Read, Write, Edit, Glob, Grep in both spawnWarm() and callClaude()
cold-start paths. Bumped version to 1.3.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 13:44:36 +10:00
taodengandClaude Opus 4.6 606fc255e2 bump version to 1.1.0
Changes since 1.0.0:
- SSE streaming support for OpenClaw compatibility
- --allowedTools flag for non-interactive tool approval
- Recovery and troubleshooting docs in README
- setup.mjs auto-configuration script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:26:34 +10:00
taodengandClaude Opus 4.6 6d708a602f docs: translate recovery section to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:22:53 +10:00
taodengandClaude Opus 4.6 57df919873 docs: add upgrade recovery guide and troubleshooting table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:05:13 +10:00
taodengandClaude Opus 4.6 15fee08b32 Improve README with compelling intro and feature highlights
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 22:34:00 +10:00
taodengandClaude Opus 4.6 92c55df021 Pre-approve common tools (Bash, Read, Write, Edit, Glob, Grep)
claude -p in non-interactive mode cannot show approval prompts,
so tools like gh/git were blocked. Add --allowedTools to enable
tool execution without manual approval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 22:10:09 +10:00
12 changed files with 2048 additions and 114 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.gitignore
*.md
node_modules
.env
.env.*
scripts/
start.sh
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY server.mjs ./
COPY setup.mjs ./
COPY package.json ./
ENV CLAUDE_SESSION_TOKEN="" \
CLAUDE_COOKIES=""
EXPOSE 3456
CMD ["node", "server.mjs"]
+175 -17
View File
@@ -1,6 +1,64 @@
# openclaw-claude-proxy
Use your Claude Pro/Max subscription as an OpenClaw model provider — no API key needed.
> **Already paying for Claude Pro/Max? Use it as your OpenClaw model provider — $0 extra API cost.**
A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run.
## 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
@@ -12,18 +70,17 @@ The proxy translates OpenAI-compatible `/v1/chat/completions` requests into `cla
## Prerequisites
- **Node.js** 18
- **Node.js** >= 18
- **Claude CLI** installed and authenticated (`claude login`)
- **OpenClaw** installed
## Quick Install
## Quick Start (Node.js)
```bash
# Clone
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
cd openclaw-claude-proxy
# Auto-configure OpenClaw + start proxy
# Auto-configure OpenClaw + start proxy + install auto-start
node setup.mjs
```
@@ -32,6 +89,7 @@ That's it. The setup script will:
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)
Then set your preferred Claude model as default:
```bash
@@ -39,6 +97,45 @@ 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:
```bash
node uninstall.mjs
```
## Manual Install
### 1. Start the proxy
@@ -57,7 +154,7 @@ Add to `~/.openclaw/openclaw.json` under `models.providers`:
"claude-local": {
"baseUrl": "http://127.0.0.1:3456/v1",
"api": "openai-completions",
"authHeader": false,
"apiKey": "<your PROXY_API_KEY, or omit if auth disabled>",
"models": [
{
"id": "claude-opus-4-6",
@@ -101,37 +198,98 @@ openclaw gateway restart
| Model ID | Claude CLI model | Notes |
|----------|-----------------|-------|
| `claude-opus-4-6` | opus | Most capable, slower |
| `claude-sonnet-4-6` | sonnet | Good balance of speed/quality |
| `claude-haiku-4` | haiku | Fastest, lightweight |
| `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 |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | `claude` | Path to claude binary |
| `CLAUDE_TIMEOUT` | `120000` | Request timeout (ms) |
| `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_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 |
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
## API Endpoints
- `GET /v1/models` — List available models
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming)
- `GET /health`Health check
- `GET /health`Comprehensive health check (stats, sessions, auth, config)
- `GET /sessions` — List active sessions
- `DELETE /sessions` — Clear all sessions
## Auto-start on Login (macOS)
## 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.
Add to your `~/.zshrc`:
```bash
bash ~/.openclaw/projects/claude-proxy/start.sh 2>/dev/null
# 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
- The `🔑` field in `/status` may show the dummy auth key — this is normal
- Each request spawns a `claude -p` process; concurrent requests are supported
- 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)
## License
+7
View File
@@ -0,0 +1,7 @@
services:
claude-proxy:
build: .
ports:
- "3456:3456"
env_file: .env
restart: unless-stopped
+182
View File
@@ -0,0 +1,182 @@
# openclaw-claude-proxy v2.3.0
Use your **Claude Pro / Max** subscription as an **OpenAI-compatible local endpoint**.
`openclaw-claude-proxy` accepts OpenAI-style chat completion requests, then runs them through the local `claude` CLI. That means tools which only know how to talk to an OpenAI API can still use Claude models through a local base URL.
## Why v2 matters
v2 is not just a bugfix release. It changes the runtime model:
- **On-demand spawning** instead of fragile warm pools
- **Session resume** support for multi-turn conversations
- **Faster fallback** with first-byte timeout + lower default request timeout
- **Full tool access** via configurable allowed tools
- **MCP config + system prompt pass-through**
- **Health / sessions / diagnostics endpoints**
- **Safe coexistence with Claude Code channel / interactive mode**
## The short pitch
If Claude's new channel workflow feels useful, OCP v2 now covers the same practical ground for many local agent/tooling setups:
- multi-turn continuity
- tool-enabled Claude runs
- local orchestration
- stable process isolation
- coexistence with your normal Claude Code workflow
And it adds a few advantages that channel users usually still want:
- **OpenAI-compatible HTTP API** for existing tools
- **Works with OpenClaw, Cursor, Continue, Open WebUI, LangChain, and anything with custom base URL support**
- **Explicit health checks and diagnostics**
- **Model/provider failover can happen outside Claude itself**
- **No lock-in to a single client UX**
## Coexistence with Claude Code channel
This is the important part: **OCP v2 does not replace Claude Code channel, and it does not need to. They can coexist on the same machine.**
### Claude Code channel / interactive mode
- persistent interactive workflow
- MCP protocol / in-process experience
- great when you are directly driving Claude Code
### OCP v2
- local HTTP server on `localhost`
- OpenAI-compatible API surface
- per-request `claude -p` execution with session resume when you want continuity
- ideal for external tools, routers, orchestrators, OpenClaw providers, and local automation
### Practical takeaway
Use both:
- use **Claude Code channel** when you want Claude's native interactive workflow
- use **OCP v2** when another app expects an OpenAI-style API but you still want to use Claude
They solve adjacent problems, not identical ones.
## Unique advantages of OCP v2
1. **API compatibility**
- Drop into tools that already support OpenAI-compatible endpoints.
- No need to wait for each tool to add native Claude channel support.
2. **Routing freedom**
- Put OCP behind OpenClaw or another router.
- Mix Claude with fallback providers outside the Claude client itself.
3. **Operational visibility**
- `/health`, `/sessions`, recent errors, auth state, resolved binary path, timeout config.
- Much easier to debug than a black-box local integration.
4. **Safer runtime model**
- v2 removes the old pre-spawn pool crash loop.
- No stale workers, no degraded warm pool states, fewer hidden failure modes.
5. **Configurable tools and behavior**
- allowed tools
- skip permissions mode
- system prompt append
- MCP config passthrough
- session TTL
- concurrency limits
## Install
```bash
git clone https://github.com/dtzp555-max/openclaw-claude-proxy
cd openclaw-claude-proxy
npm install
node server.mjs
```
Default base URL:
```text
http://127.0.0.1:3456/v1
```
## Quick OpenAI-compatible config
```json
{
"baseURL": "http://127.0.0.1:3456/v1",
"apiKey": "anything"
}
```
If `PROXY_API_KEY` is unset, auth is disabled. If you set it, pass it as a Bearer token.
## Environment variables
| Variable | Default | Purpose |
|---|---:|---|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | auto-detect | Claude CLI binary path |
| `CLAUDE_TIMEOUT` | `120000` | Overall per-request timeout |
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `30000` | Abort if Claude produces no stdout quickly |
| `CLAUDE_ALLOWED_TOOLS` | expanded set | Comma-separated allowed tools |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass permission checks |
| `CLAUDE_SYSTEM_PROMPT` | unset | Append a system prompt to every request |
| `CLAUDE_MCP_CONFIG` | unset | Path to MCP config JSON |
| `CLAUDE_SESSION_TTL` | `3600000` | Session TTL |
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent Claude processes |
| `PROXY_API_KEY` | unset | Optional Bearer token auth |
## Endpoints
- `GET /health`
- `GET /v1/models`
- `POST /v1/chat/completions`
- `GET /sessions`
- `DELETE /sessions`
## Example health response highlights
`/health` reports useful operational state such as:
- resolved Claude binary path
- whether the binary is executable
- auth status
- timeouts
- current sessions
- recent errors
- basic request stats
## Version highlights
### v2.3.0
- clarified v2 positioning and coexistence story in docs
- officially documents faster fallback defaults
- recommends OCP v2 as the API bridge layer for Claude-powered tools
### v2.2.0
- first-byte timeout
- reduced default timeout for faster fallback
### v2.0.0
- on-demand architecture
- session management
- full tool access
- MCP + system prompt passthrough
- concurrency control
- coexistence with Claude Code interactive mode
## When to use OCP v2 vs Claude channel
Choose **OCP v2** when:
- your app only supports OpenAI-compatible endpoints
- you want routing / failover outside Claude
- you want explicit health checks and local diagnostics
- you want Claude available to multiple local tools through one endpoint
Choose **Claude channel** when:
- you are primarily living inside Claude Code itself
- you want Claude's native interactive workflow directly
Use **both together** when you want the best of both worlds.
---
If you already pay for Claude Pro or Max, OCP v2 turns that subscription into a practical local API bridge for the rest of your tooling stack.
+28
View File
@@ -0,0 +1,28 @@
{
"name": "openclaw-claude-proxy",
"version": "2.4.0",
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
"type": "module",
"bin": {
"openclaw-claude-proxy": "./server.mjs"
},
"scripts": {
"start": "node server.mjs",
"setup": "node setup.mjs"
},
"keywords": [
"openclaw",
"claude",
"proxy",
"openai",
"anthropic"
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"repository": {
"type": "git",
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
}
}
+643
View File
@@ -0,0 +1,643 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* v2.4.0:
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
* - Adaptive first-byte timeout: scales by model tier + prompt size
* - Structured JSON logging for key events (easier to parse/alert on)
* - On-demand spawning (no pool), session management, full tool access
*
* Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: auto-detect)
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
* CLAUDE_BREAKER_THRESHOLD — consecutive timeouts before circuit opens (default: 3)
* CLAUDE_BREAKER_COOLDOWN — ms to wait before retrying after circuit opens (default: 60000)
* PROXY_API_KEY — Bearer token for API auth (optional)
*/
import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
// ── Resolve claude binary ───────────────────────────────────────────────
// Priority: CLAUDE_BIN env > well-known paths > which lookup
// Fail-fast if not found — never start with an unresolvable binary.
function resolveClaude() {
if (process.env.CLAUDE_BIN) {
try {
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
return process.env.CLAUDE_BIN;
} catch {
console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`);
process.exit(1);
}
}
const candidates = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
join(process.env.HOME || "", ".local/bin/claude"),
];
for (const p of candidates) {
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
}
try {
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
} catch {}
console.error(
"FATAL: claude binary not found.\n" +
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Checked: " + candidates.join(", ")
);
process.exit(1);
}
// ── Configuration ───────────────────────────────────────────────────────
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "45000", 10);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
).split(",").map(s => s.trim()).filter(Boolean);
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "3", 10);
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "60000", 10);
const VERSION = _pkg.version;
const START_TIME = Date.now();
// ── Structured logging helper ───────────────────────────────────────────
function logEvent(level, event, data = {}) {
const entry = { ts: new Date().toISOString(), level, event, ...data };
if (level === "error" || level === "warn") {
console.error(JSON.stringify(entry));
} else {
console.log(JSON.stringify(entry));
}
}
// ── Per-model circuit breaker ───────────────────────────────────────────
// Tracks consecutive timeouts per model. When threshold is reached, the
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
// window, requests for this model fail fast with a clear error instead of
// waiting for yet another timeout that would block the gateway.
const breakers = new Map(); // cliModel → { failures, state, openedAt }
function getBreakerState(cliModel) {
if (!breakers.has(cliModel)) {
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
}
const b = breakers.get(cliModel);
// Auto-recover: if cooldown has elapsed, transition to half-open
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
b.state = "half-open";
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
}
return b;
}
function breakerRecordSuccess(cliModel) {
const b = getBreakerState(cliModel);
if (b.failures > 0 || b.state !== "closed") {
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
}
b.failures = 0;
b.state = "closed";
b.openedAt = 0;
}
function breakerRecordTimeout(cliModel) {
const b = getBreakerState(cliModel);
b.failures++;
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
b.state = "open";
b.openedAt = Date.now();
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
}
}
// ── Model mapping ───────────────────────────────────────────────────────
// Maps request model IDs and aliases to canonical claude CLI model IDs.
const MODEL_MAP = {
"claude-opus-4-6": "claude-opus-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
"claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "claude-haiku-4-5-20251001",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"opus": "claude-opus-4-6",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001",
};
const MODELS = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
];
// ── Session management ──────────────────────────────────────────────────
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
// Enables --resume for multi-turn conversations, reducing token waste.
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
setInterval(() => {
const now = Date.now();
for (const [id, s] of sessions) {
if (now - s.lastUsed > SESSION_TTL) {
sessions.delete(id);
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
}
}
}, 60000);
// ── Stats & diagnostics ─────────────────────────────────────────────────
const stats = {
totalRequests: 0,
activeRequests: 0,
errors: 0,
timeouts: 0,
sessionHits: 0,
sessionMisses: 0,
oneOffRequests: 0,
};
const recentErrors = []; // last 20 errors
function trackError(msg) {
stats.errors++;
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
if (recentErrors.length > 20) recentErrors.shift();
}
// ── Auth health check ───────────────────────────────────────────────────
let authStatus = { ok: null, lastCheck: 0, message: "" };
async function checkAuth() {
try {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
} catch (e) {
const msg = (e.stderr || e.message || "").slice(0, 200);
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
console.error(`[auth] check failed: ${msg}`);
}
}
// Check auth on start and every 10 minutes
checkAuth();
setInterval(checkAuth, 600000);
// ── Build CLI arguments ─────────────────────────────────────────────────
function buildCliArgs(cliModel, sessionInfo) {
const args = ["-p", "--model", cliModel, "--output-format", "text"];
// Session handling
if (sessionInfo?.resume) {
args.push("--resume", sessionInfo.uuid);
} else if (sessionInfo?.uuid) {
args.push("--session-id", sessionInfo.uuid);
} else {
args.push("--no-session-persistence");
}
// Permissions
if (SKIP_PERMISSIONS) {
args.push("--dangerously-skip-permissions");
} else if (ALLOWED_TOOLS.length > 0) {
args.push("--allowedTools", ...ALLOWED_TOOLS);
}
// System prompt
if (SYSTEM_PROMPT) {
args.push("--append-system-prompt", SYSTEM_PROMPT);
}
// MCP config
if (MCP_CONFIG) {
args.push("--mcp-config", MCP_CONFIG);
}
return args;
}
// ── Format messages to prompt text ──────────────────────────────────────
function messagesToPrompt(messages) {
return messages.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
}).join("\n\n");
}
// Model tier multipliers for first-byte timeout.
// Opus is much slower to produce first token, especially with large contexts.
const MODEL_TIMEOUT_TIERS = {
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
};
function getModelTier(cliModel) {
if (cliModel.includes("opus")) return "opus";
if (cliModel.includes("haiku")) return "haiku";
return "sonnet";
}
function computeFirstByteTimeout(cliModel, promptLength) {
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
}
// ── Call claude CLI ─────────────────────────────────────────────────────
// On-demand spawning: each request spawns a fresh `claude -p` process.
// No pool = no crash loops, no stale workers, no degraded states.
// Stdin is written immediately so there's no 3s stdin timeout issue.
function callClaude(model, messages, conversationId) {
return new Promise((resolve, reject) => {
if (stats.activeRequests >= MAX_CONCURRENT) {
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
}
const cliModel = MODEL_MAP[model] || model;
// Circuit breaker check: fail fast if model is in open state
const breaker = getBreakerState(cliModel);
if (breaker.state === "open") {
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
}
stats.activeRequests++;
stats.totalRequests++;
let sessionInfo = null;
let prompt;
// ── Session logic ──
if (conversationId && sessions.has(conversationId)) {
// Resume existing session: only send the latest user message
const session = sessions.get(conversationId);
session.lastUsed = Date.now();
sessionInfo = { uuid: session.uuid, resume: true };
stats.sessionHits++;
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
prompt = lastUserMsg
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
: "";
session.messageCount = messages.length;
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
} else if (conversationId) {
// New session: send all messages, persist session for future --resume
const uuid = randomUUID();
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
sessionInfo = { uuid, resume: false };
stats.sessionMisses++;
prompt = messagesToPrompt(messages);
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
} else {
// One-off request, no session
stats.oneOffRequests++;
prompt = messagesToPrompt(messages);
}
const cliArgs = buildCliArgs(cliModel, sessionInfo);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const t0 = Date.now();
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
let settled = false;
let gotFirstByte = false;
function settle(err, result) {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(firstByteTimer);
stats.activeRequests--;
if (err) {
trackError(err.message || String(err));
// If session resume failed, remove session so next request starts fresh
if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
sessions.delete(conversationId);
}
reject(err);
} else {
resolve(result);
}
}
proc.stdout.on("data", (d) => {
if (!gotFirstByte) {
gotFirstByte = true;
clearTimeout(firstByteTimer);
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
}
stdout += d;
});
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code, signal) => {
const elapsed = Date.now() - t0;
if (settled) {
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
return;
}
if (code !== 0) {
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
} else {
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
settle(null, stdout.trim());
}
});
proc.on("error", (err) => {
console.error(`[claude] spawn error: ${err.message}`);
settle(err);
});
// Write prompt to stdin immediately — no idle timeout issue
proc.stdin.write(prompt);
proc.stdin.end();
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// First-byte timeout: abort early if Claude CLI produces no output
const firstByteTimer = setTimeout(() => {
if (!gotFirstByte && !settled) {
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
}
}, firstByteTimeoutMs);
// Overall request timeout with graceful kill
const timer = setTimeout(() => {
if (settled) return;
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
settle(new Error(`timeout after ${TIMEOUT}ms`));
}, TIMEOUT);
});
}
// ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function sendSSE(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function streamResponse(res, id, model, content) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const created = Math.floor(Date.now() / 1000);
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
for (let i = 0; i < content.length; i += 500) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
});
}
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
res.write("data: [DONE]\n\n");
res.end();
}
function completionResponse(res, id, model, content) {
jsonResponse(res, 200, {
id, object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
// ── Handle chat completions ─────────────────────────────────────────────
async function handleChatCompletions(req, res) {
let body = "";
for await (const chunk of req) body += chunk;
let parsed;
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
const model = parsed.model || "claude-sonnet-4-6";
const stream = parsed.stream;
// Session ID: from request body, header, or null (one-off)
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
try {
const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`;
if (stream) {
streamResponse(res, id, model, content);
} else {
completionResponse(res, id, model, content);
}
} catch (err) {
console.error(`[proxy] error: ${err.message}`);
if (res.headersSent || res.writableEnded || res.destroyed) {
try { res.end(); } catch {}
return;
}
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
}
}
// ── HTTP server ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
if (PROXY_API_KEY && req.url !== "/health") {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (token !== PROXY_API_KEY) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
}
}
// GET /v1/models
if (req.url === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, {
object: "list",
data: MODELS.map((m) => ({
id: m.id, object: "model", owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
})),
});
}
// POST /v1/chat/completions
if (req.url === "/v1/chat/completions" && req.method === "POST") {
return handleChatCompletions(req, res);
}
// GET /health — comprehensive diagnostics
if (req.url === "/health") {
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
const uptimeMs = Date.now() - START_TIME;
const sessionList = [];
for (const [id, s] of sessions) {
sessionList.push({
id: id.slice(0, 12) + "...",
model: s.model,
messages: s.messageCount,
idleMs: Date.now() - s.lastUsed,
});
}
return jsonResponse(res, 200, {
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
version: VERSION,
architecture: "on-demand (v2)",
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
auth: authStatus,
config: {
timeout: TIMEOUT,
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
maxConcurrent: MAX_CONCURRENT,
sessionTTL: SESSION_TTL,
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
mcpConfig: MCP_CONFIG || "(none)",
},
stats,
sessions: sessionList,
recentErrors: recentErrors.slice(-5),
});
}
// DELETE /sessions — clear all sessions
if (req.url === "/sessions" && req.method === "DELETE") {
const count = sessions.size;
sessions.clear();
return jsonResponse(res, 200, { cleared: count });
}
// GET /sessions — list active sessions
if (req.url === "/sessions" && req.method === "GET") {
const list = [];
for (const [id, s] of sessions) {
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
}
return jsonResponse(res, 200, { sessions: list });
}
// Catch-all POST
if (req.method === "POST") {
return handleChatCompletions(req, res);
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
});
// ── Start ───────────────────────────────────────────────────────────────
server.listen(PORT, "0.0.0.0", () => {
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
console.log(` Both can run simultaneously on the same machine.`);
});
+9 -3
View File
@@ -1,7 +1,7 @@
{
"name": "openclaw-claude-proxy",
"version": "1.0.0",
"description": "OpenAI-compatible proxy that routes requests through Claude CLI — use your Claude Pro/Max subscription as an OpenClaw model provider",
"version": "2.5.0",
"description": "OpenAI-compatible proxy for Claude CLI v2 — sliding-window circuit breaker, adaptive first-byte timeout, structured logging",
"type": "module",
"bin": {
"openclaw-claude-proxy": "./server.mjs"
@@ -10,7 +10,13 @@
"start": "node server.mjs",
"setup": "node setup.mjs"
},
"keywords": ["openclaw", "claude", "proxy", "openai", "anthropic"],
"keywords": [
"openclaw",
"claude",
"proxy",
"openai",
"anthropic"
],
"license": "MIT",
"engines": {
"node": ">=18"
+820 -86
View File
@@ -1,78 +1,680 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
* openclaw-claude-proxy v2.5.0 — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* Supports both streaming (SSE) and non-streaming responses.
* 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
*
* Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: "claude")
* 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_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8)
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
* CLAUDE_BREAKER_HALF_OPEN_MAX — max concurrent probes in half-open state (default: 2)
* PROXY_API_KEY — Bearer token for API auth (optional)
*/
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { spawn, execFileSync } from "node:child_process";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
// ── Resolve claude binary ───────────────────────────────────────────────
// Priority: CLAUDE_BIN env > well-known paths > which lookup
// Fail-fast if not found — never start with an unresolvable binary.
function resolveClaude() {
if (process.env.CLAUDE_BIN) {
try {
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
return process.env.CLAUDE_BIN;
} catch {
console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`);
process.exit(1);
}
}
const candidates = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
join(process.env.HOME || "", ".local/bin/claude"),
];
for (const p of candidates) {
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
}
try {
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
} catch {}
console.error(
"FATAL: claude binary not found.\n" +
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Checked: " + candidates.join(", ")
);
process.exit(1);
}
// ── Configuration ───────────────────────────────────────────────────────
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = process.env.CLAUDE_BIN || "claude";
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 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);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
).split(",").map(s => s.trim()).filter(Boolean);
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "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);
const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10);
// Model alias mapping: request model → claude CLI --model arg
const VERSION = _pkg.version;
const START_TIME = Date.now();
// ── Structured logging helper ───────────────────────────────────────────
function logEvent(level, event, data = {}) {
const entry = { ts: new Date().toISOString(), level, event, ...data };
if (level === "error" || level === "warn") {
console.error(JSON.stringify(entry));
} else {
console.log(JSON.stringify(entry));
}
}
// ── Per-model 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
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
};
}
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.
const MODEL_MAP = {
"claude-opus-4-6": "opus",
"claude-opus-4": "opus",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4": "sonnet",
"claude-haiku-4": "haiku",
"claude-opus-4-6": "claude-opus-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
"claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "claude-haiku-4-5-20251001",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"opus": "claude-opus-4-6",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001",
};
const MODELS = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4", name: "Claude Haiku 4" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
];
// ── Call claude CLI ─────────────────────────────────────────────────────
function callClaude(model, messages) {
return new Promise((resolve, reject) => {
const prompt = messages
.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
})
.join("\n\n");
// ── Session management ──────────────────────────────────────────────────
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
// Enables --resume for multi-turn conversations, reducing token waste.
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
const cliModel = MODEL_MAP[model] || model;
const args = ["-p", "--model", cliModel, "--output-format", "text", "--no-session-persistence", "--", prompt];
const sessionCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [id, s] of sessions) {
if (now - s.lastUsed > SESSION_TTL) {
sessions.delete(id);
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
}
}
}, 60000);
// ── Active child process tracking ────────────────────────────────────────
const activeProcesses = new Set();
// ── Stats & diagnostics ─────────────────────────────────────────────────
const stats = {
totalRequests: 0,
activeRequests: 0,
errors: 0,
timeouts: 0,
sessionHits: 0,
sessionMisses: 0,
oneOffRequests: 0,
};
const recentErrors = []; // last 20 errors
function trackError(msg) {
stats.errors++;
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
if (recentErrors.length > 20) recentErrors.shift();
}
// ── Auth health check ───────────────────────────────────────────────────
let authStatus = { ok: null, lastCheck: 0, message: "" };
async function checkAuth() {
try {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
} catch (e) {
const msg = (e.stderr || e.message || "").slice(0, 200);
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
console.error(`[auth] check failed: ${msg}`);
}
}
// Check auth on start and every 10 minutes
checkAuth();
const authCheckInterval = setInterval(checkAuth, 600000);
// ── Build CLI arguments ─────────────────────────────────────────────────
function buildCliArgs(cliModel, sessionInfo) {
const args = ["-p", "--model", cliModel, "--output-format", "text"];
// Session handling
if (sessionInfo?.resume) {
args.push("--resume", sessionInfo.uuid);
} else if (sessionInfo?.uuid) {
args.push("--session-id", sessionInfo.uuid);
} else {
args.push("--no-session-persistence");
}
// Permissions
if (SKIP_PERMISSIONS) {
args.push("--dangerously-skip-permissions");
} else if (ALLOWED_TOOLS.length > 0) {
args.push("--allowedTools", ...ALLOWED_TOOLS);
}
// System prompt
if (SYSTEM_PROMPT) {
args.push("--append-system-prompt", SYSTEM_PROMPT);
}
// MCP config
if (MCP_CONFIG) {
args.push("--mcp-config", MCP_CONFIG);
}
return args;
}
// ── Format messages to prompt text ──────────────────────────────────────
function messagesToPrompt(messages) {
return messages.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
}).join("\n\n");
}
// 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
};
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.
function spawnClaudeProcess(model, messages, conversationId) {
if (stats.activeRequests >= MAX_CONCURRENT) {
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
}
const cliModel = MODEL_MAP[model] || model;
// Circuit breaker check: fail fast if model is in open state
const breaker = getBreakerState(cliModel);
if (breaker.state === "open") {
const remainingMs = breaker.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 });
}
stats.activeRequests++;
stats.totalRequests++;
let sessionInfo = null;
let prompt;
// ── Session logic ──
if (conversationId && sessions.has(conversationId)) {
const session = sessions.get(conversationId);
session.lastUsed = Date.now();
sessionInfo = { uuid: session.uuid, resume: true };
stats.sessionHits++;
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
prompt = lastUserMsg
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
: "";
session.messageCount = messages.length;
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
} else if (conversationId) {
const uuid = randomUUID();
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
sessionInfo = { uuid, resume: false };
stats.sessionMisses++;
prompt = messagesToPrompt(messages);
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
} else {
stats.oneOffRequests++;
prompt = messagesToPrompt(messages);
}
const cliArgs = buildCliArgs(cliModel, sessionInfo);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
activeProcesses.add(proc);
const t0 = Date.now();
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
let gotFirstByte = false;
let cleaned = false;
function cleanup() {
if (cleaned) return;
cleaned = true;
clearTimeout(overallTimer);
clearTimeout(firstByteTimer);
stats.activeRequests--;
}
function handleSessionFailure() {
if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
sessions.delete(conversationId);
}
}
function markFirstByte() {
if (!gotFirstByte) {
gotFirstByte = true;
clearTimeout(firstByteTimer);
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
}
}
// Write prompt to stdin immediately
proc.stdin.write(prompt);
proc.stdin.end();
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// First-byte timeout
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
const overallTimer = setTimeout(() => {
if (!cleaned) {
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
}
}, TIMEOUT);
return { proc, cliModel, conversationId, t0, cleanup, handleSessionFailure, markFirstByte };
}
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
// On-demand spawning: each request spawns a fresh `claude -p` process.
// No pool = no crash loops, no stale workers, no degraded states.
// Stdin is written immediately so there's no 3s stdin timeout issue.
function callClaude(model, messages, conversationId) {
return new Promise((resolve, reject) => {
let ctx;
try {
ctx = spawnClaudeProcess(model, messages, conversationId);
} catch (err) {
return reject(err);
}
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
let stdout = "";
let stderr = "";
const proc = spawn(CLAUDE, args, { env, stdio: ["ignore", "pipe", "pipe"] });
proc.stdout.on("data", (d) => (stdout += d));
proc.stdout.on("data", (d) => {
markFirstByte();
stdout += d;
});
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code) => {
proc.on("close", (code, signal) => {
activeProcesses.delete(proc);
const elapsed = Date.now() - t0;
cleanup();
if (code !== 0) {
console.error(`[claude] exit=${code} model=${cliModel} stderr=${stderr.slice(0, 300)}`);
reject(new Error(stderr || stdout || `exit ${code}`));
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 {
console.log(`[claude] ok model=${cliModel} chars=${stdout.length}`);
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
resolve(stdout.trim());
}
});
proc.on("error", reject);
const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT);
proc.on("close", () => clearTimeout(timer));
proc.on("error", (err) => {
console.error(`[claude] spawn error: ${err.message}`);
cleanup();
trackError(err.message);
handleSessionFailure();
reject(err);
});
});
}
// ── Call claude CLI (real streaming) ─────────────────────────────────────
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
// Each data chunk becomes a proper SSE event with delta content in real time.
function callClaudeStreaming(model, messages, conversationId, res) {
const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
let ctx;
try {
ctx = spawnClaudeProcess(model, messages, conversationId);
} catch (err) {
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
}
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
let stderr = "";
let headersSent = false;
let totalChars = 0;
function ensureHeaders() {
if (headersSent || res.writableEnded || res.destroyed) return false;
headersSent = true;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
// Send initial role chunk
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
return true;
}
proc.stdout.on("data", (d) => {
markFirstByte();
const text = d.toString();
totalChars += text.length;
if (!ensureHeaders()) return;
// Stream each chunk as it arrives from the CLI process
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
});
});
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code, signal) => {
activeProcesses.delete(proc);
cleanup();
const elapsed = Date.now() - t0;
if (code !== 0) {
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" }],
});
res.write("data: [DONE]\n\n");
res.end();
}
} else {
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
if (!headersSent) ensureHeaders();
if (!res.writableEnded && !res.destroyed) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
res.write("data: [DONE]\n\n");
res.end();
}
}
});
proc.on("error", (err) => {
console.error(`[claude] spawn error: ${err.message}`);
cleanup();
trackError(err.message);
handleSessionFailure();
if (!headersSent && !res.writableEnded && !res.destroyed) {
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
} else if (!res.writableEnded && !res.destroyed) {
res.end();
}
});
// If client disconnects, kill the process to free resources
res.on("close", () => {
if (!proc.killed) {
try { proc.kill("SIGTERM"); } catch {}
}
});
}
// ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
@@ -81,34 +683,6 @@ function sendSSE(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function streamResponse(res, id, model, content) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const created = Math.floor(Date.now() / 1000);
// Role chunk
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
// Content chunks (~500 chars each)
for (let i = 0; i < content.length; i += 500) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
});
}
// Finish
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
res.write("data: [DONE]\n\n");
res.end();
}
function completionResponse(res, id, model, content) {
jsonResponse(res, 200, {
id, object: "chat.completion",
@@ -120,9 +694,19 @@ function completionResponse(res, id, model, content) {
}
// ── Handle chat completions ─────────────────────────────────────────────
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB
// Set of all valid model identifiers (canonical IDs + aliases)
const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
async function handleChatCompletions(req, res) {
let body = "";
for await (const chunk of req) body += chunk;
for await (const chunk of req) {
body += chunk;
if (body.length > MAX_BODY_SIZE) {
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
}
}
let parsed;
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
@@ -131,30 +715,58 @@ async function handleChatCompletions(req, res) {
const model = parsed.model || "claude-sonnet-4-6";
const stream = parsed.stream;
// Validate model against known models
if (!VALID_MODELS.has(model)) {
return jsonResponse(res, 400, { error: { message: `Unknown model: ${model}. Valid models: ${[...VALID_MODELS].join(", ")}`, type: "invalid_request_error" } });
}
// Session ID: from request body, header, or null (one-off)
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
try {
const content = await callClaude(model, messages);
const id = `chatcmpl-${randomUUID()}`;
if (stream) {
// Real streaming: pipe stdout from claude process directly as SSE chunks
return callClaudeStreaming(model, messages, conversationId, res);
}
if (stream) {
streamResponse(res, id, model, content);
} else {
completionResponse(res, id, model, content);
}
try {
const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content);
} catch (err) {
console.error(`[proxy] error: ${err.message}`);
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
if (res.headersSent || res.writableEnded || res.destroyed) {
try { res.end(); } catch {}
return;
}
// Sanitize error: strip internal file paths before sending to client
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
}
}
// ── HTTP server ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
// Dynamic CORS: only allow localhost origins
const origin = req.headers["origin"] || "";
const isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : `http://127.0.0.1:${PORT}`);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
if (PROXY_API_KEY && req.url !== "/health") {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
const tokenBuf = Buffer.from(token);
const keyBuf = Buffer.from(PROXY_API_KEY);
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
}
}
// GET /v1/models
if (req.url === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, {
@@ -171,20 +783,142 @@ const server = createServer(async (req, res) => {
return handleChatCompletions(req, res);
}
// Health check
if (req.url === "/health") return jsonResponse(res, 200, { status: "ok" });
// GET /health — comprehensive diagnostics
if (req.url === "/health") {
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
// Catch-all: try to handle any POST with messages
if (req.method === "POST") {
return handleChatCompletions(req, res);
const uptimeMs = Date.now() - START_TIME;
const sessionList = [];
for (const [id, s] of sessions) {
sessionList.push({
id: id.slice(0, 12) + "...",
model: s.model,
messages: s.messageCount,
idleMs: Date.now() - s.lastUsed,
});
}
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",
version: VERSION,
architecture: "on-demand (v2)",
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
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,
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
mcpConfig: MCP_CONFIG || "(none)",
},
stats,
breakers: breakerState,
sessions: sessionList,
recentErrors: recentErrors.slice(-5),
});
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
// DELETE /sessions — clear all sessions
if (req.url === "/sessions" && req.method === "DELETE") {
const count = sessions.size;
sessions.clear();
return jsonResponse(res, 200, { cleared: count });
}
// GET /sessions — list active sessions
if (req.url === "/sessions" && req.method === "GET") {
const list = [];
for (const [id, s] of sessions) {
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
}
return jsonResponse(res, 200, { sessions: list });
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
});
// ── Graceful shutdown ────────────────────────────────────────────────────
let shuttingDown = false;
function gracefulShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
logEvent("info", "shutdown_start", { signal });
// 1. Stop accepting new connections
server.close(() => {
logEvent("info", "shutdown_server_closed", {});
});
// 2. Clear intervals/timers
clearInterval(sessionCleanupInterval);
clearInterval(authCheckInterval);
// 3. Kill all active child processes
for (const proc of activeProcesses) {
try { proc.kill("SIGTERM"); } catch {}
}
// Force-kill any remaining processes after 5s, then exit
const forceExitTimer = setTimeout(() => {
for (const proc of activeProcesses) {
try { proc.kill("SIGKILL"); } catch {}
}
logEvent("warn", "shutdown_forced", { remainingProcesses: activeProcesses.size });
process.exit(1);
}, 5000);
forceExitTimer.unref();
// If no active processes, exit immediately
if (activeProcesses.size === 0) {
logEvent("info", "shutdown_complete", {});
process.exit(0);
}
// Wait for active processes to finish
const checkDone = setInterval(() => {
if (activeProcesses.size === 0) {
clearInterval(checkDone);
logEvent("info", "shutdown_complete", {});
process.exit(0);
}
}, 200);
checkDone.unref();
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
// ── Start ───────────────────────────────────────────────────────────────
server.listen(PORT, "127.0.0.1", () => {
console.log(`openclaw-claude-proxy listening on http://127.0.0.1:${PORT}`);
console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`);
console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms`);
console.log(`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(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
console.log(` Both can run simultaneously on the same machine.`);
});
+94 -1
View File
@@ -119,7 +119,7 @@ try {
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
encoding: "utf-8",
timeout: 30000,
env: { ...process.env, CLAUDECODE: undefined },
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
}).trim();
if (out.length > 0) {
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
@@ -279,3 +279,96 @@ if (!SKIP_START && !DRY_RUN) {
execSync(`bash "${startPath}"`, { stdio: "inherit" });
} catch { /* ignore */ }
}
// ── Step 7: Install auto-start on boot ──────────────────────────────────
if (!DRY_RUN) {
console.log("\n🔄 Installing auto-start on login...\n");
const platform = process.platform;
const nodeBin = process.execPath;
// Ensure logs dir exists
const logsDir = join(OPENCLAW_DIR, "logs");
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
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 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>
<key>ProgramArguments</key>
<array>
<string>${nodeBin}</string>
<string>${serverPath}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_PROXY_PORT</key>
<string>${PORT}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${logPath}</string>
<key>StandardErrorPath</key>
<string>${logPath}</string>
</dict>
</plist>
`;
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`);
} 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 serviceUnit = `[Unit]
Description=OpenClaw Claude Proxy
After=network.target
[Service]
ExecStart=${nodeBin} ${serverPath}
Environment=CLAUDE_PROXY_PORT=${PORT}
Restart=always
StandardOutput=append:${logPath}
StandardError=append:${logPath}
[Install]
WantedBy=default.target
`;
writeFileSync(servicePath, serviceUnit);
log(`Service file written: ${servicePath}`);
execSync(`systemctl --user daemon-reload`);
execSync(`systemctl --user enable openclaw-proxy`);
execSync(`systemctl --user start openclaw-proxy`);
log(`systemd user service enabled and started`);
} else {
warn(`Auto-start not supported on ${platform} — start manually with: bash ${startPath}`);
}
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
}
+8 -7
View File
@@ -1,11 +1,12 @@
#!/bin/bash
# Start claude-proxy if not already running
if ! lsof -i :3456 -sTCP:LISTEN &>/dev/null; then
# Start openclaw-claude-proxy if not already running
PORT=${CLAUDE_PROXY_PORT:-3456}
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE
nohup /opt/homebrew/bin/node /Users/taodeng/.openclaw/projects/claude-proxy/server.mjs \
>> ~/.openclaw/logs/claude-proxy.log \
2>> ~/.openclaw/logs/claude-proxy.err.log &
echo "claude-proxy started (pid $!)"
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/openclaw-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 $!)"
else
echo "claude-proxy already running"
echo "claude-proxy already running on port $PORT"
fi
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy uninstaller
*
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
* Run: node uninstall.mjs
*/
import { existsSync, unlinkSync } from "node:fs";
import { execSync } from "node:child_process";
import { join } from "node:path";
import { homedir } from "node:os";
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");
const platform = process.platform;
if (platform === "darwin") {
const plistPath = join(HOME, "Library", "LaunchAgents", "ai.openclaw.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)");
}
unlinkSync(plistPath);
log(`Plist removed: ${plistPath}`);
} else {
warn(`Plist not found: ${plistPath}`);
}
} 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");
if (existsSync(servicePath)) {
unlinkSync(servicePath);
log(`Service file removed: ${servicePath}`);
} else {
warn(`Service file not found: ${servicePath}`);
}
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
} else {
warn(`Auto-start not supported on ${platform} — nothing to remove`);
}
console.log("\n✅ Auto-start removed — proxy will no longer start on login\n");