Compare commits

...
21 Commits
Author SHA1 Message Date
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 1693 additions and 79 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"]
+136 -17
View File
@@ -1,6 +1,30 @@
# openclaw-claude-proxy # 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.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 ## How it works
@@ -12,18 +36,17 @@ The proxy translates OpenAI-compatible `/v1/chat/completions` requests into `cla
## Prerequisites ## Prerequisites
- **Node.js** 18 - **Node.js** >= 18
- **Claude CLI** installed and authenticated (`claude login`) - **Claude CLI** installed and authenticated (`claude login`)
- **OpenClaw** installed - **OpenClaw** installed
## Quick Install ## Quick Start (Node.js)
```bash ```bash
# Clone
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
cd openclaw-claude-proxy cd openclaw-claude-proxy
# Auto-configure OpenClaw + start proxy # Auto-configure OpenClaw + start proxy + install auto-start
node setup.mjs node setup.mjs
``` ```
@@ -32,6 +55,7 @@ That's it. The setup script will:
2. Add `claude-local` provider to `openclaw.json` 2. Add `claude-local` provider to `openclaw.json`
3. Add auth profiles to all agents 3. Add auth profiles to all agents
4. Start the proxy 4. Start the proxy
5. Install auto-start on login (launchd on macOS, systemd on Linux)
Then set your preferred Claude model as default: Then set your preferred Claude model as default:
```bash ```bash
@@ -39,6 +63,45 @@ openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
openclaw gateway restart 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 ## Manual Install
### 1. Start the proxy ### 1. Start the proxy
@@ -57,7 +120,7 @@ Add to `~/.openclaw/openclaw.json` under `models.providers`:
"claude-local": { "claude-local": {
"baseUrl": "http://127.0.0.1:3456/v1", "baseUrl": "http://127.0.0.1:3456/v1",
"api": "openai-completions", "api": "openai-completions",
"authHeader": false, "apiKey": "<your PROXY_API_KEY, or omit if auth disabled>",
"models": [ "models": [
{ {
"id": "claude-opus-4-6", "id": "claude-opus-4-6",
@@ -101,37 +164,93 @@ openclaw gateway restart
| Model ID | Claude CLI model | Notes | | Model ID | Claude CLI model | Notes |
|----------|-----------------|-------| |----------|-----------------|-------|
| `claude-opus-4-6` | opus | Most capable, slower | | `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
| `claude-sonnet-4-6` | sonnet | Good balance of speed/quality | | `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
| `claude-haiku-4` | haiku | Fastest, lightweight | | `claude-haiku-4` | claude-haiku-4-5-20251001 | Fastest, lightweight |
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port | | `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | `claude` | Path to claude binary | | `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `120000` | Request timeout (ms) | | `CLAUDE_TIMEOUT` | `300000` | Request timeout (ms) |
| `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` | `5` | Max concurrent claude processes |
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
## API Endpoints ## API Endpoints
- `GET /v1/models` — List available models - `GET /v1/models` — List available models
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming) - `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
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 ## Notes
- Cost shows as $0 because billing goes through your Claude subscription - 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 capped by `CLAUDE_MAX_CONCURRENT`
- Each request spawns a `claude -p` process; concurrent requests are supported
- The proxy must run on the same machine as the Claude CLI (uses local OAuth) - 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 ## 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", "name": "openclaw-claude-proxy",
"version": "1.0.0", "version": "2.4.0",
"description": "OpenAI-compatible proxy that routes requests through Claude CLI — use your Claude Pro/Max subscription as an OpenClaw model provider", "description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
"type": "module", "type": "module",
"bin": { "bin": {
"openclaw-claude-proxy": "./server.mjs" "openclaw-claude-proxy": "./server.mjs"
@@ -10,7 +10,13 @@
"start": "node server.mjs", "start": "node server.mjs",
"setup": "node setup.mjs" "setup": "node setup.mjs"
}, },
"keywords": ["openclaw", "claude", "proxy", "openai", "anthropic"], "keywords": [
"openclaw",
"claude",
"proxy",
"openai",
"anthropic"
],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+504 -51
View File
@@ -1,78 +1,452 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI * openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
* *
* Translates OpenAI chat/completions requests into `claude -p` CLI calls, * Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider. * letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
* *
* Supports both streaming (SSE) and non-streaming responses. * v2.4.0:
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
* - Adaptive first-byte timeout: scales by model tier + prompt size
* - Structured JSON logging for key events (easier to parse/alert on)
* - On-demand spawning (no pool), session management, full tool access
* *
* Env vars: * Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456) * CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: "claude") * 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 { createServer } from "node:http";
import { spawn } from "node:child_process"; import { spawn, execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto"; 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 PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = process.env.CLAUDE_BIN || "claude"; const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10); 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);
// 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 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 = { const MODEL_MAP = {
"claude-opus-4-6": "opus", "claude-opus-4-6": "claude-opus-4-6",
"claude-opus-4": "opus", "claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-sonnet-4-6": "sonnet", "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
"claude-sonnet-4": "sonnet", "claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "haiku", "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 = [ const MODELS = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" }, { id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 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 ───────────────────────────────────────────────────── // ── Session management ──────────────────────────────────────────────────
function callClaude(model, messages) { // Maps conversation IDs (from caller) to Claude CLI session UUIDs.
return new Promise((resolve, reject) => { // Enables --resume for multi-turn conversations, reducing token waste.
const prompt = messages const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
})
.join("\n\n");
const cliModel = MODEL_MAP[model] || model; setInterval(() => {
const args = ["-p", "--model", cliModel, "--output-format", "text", "--no-session-persistence", "--", prompt]; 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 }; const env = { ...process.env };
delete env.CLAUDECODE; 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 stdout = "";
let stderr = ""; let stderr = "";
const proc = spawn(CLAUDE, args, { env, stdio: ["ignore", "pipe", "pipe"] }); const t0 = Date.now();
proc.stdout.on("data", (d) => (stdout += d)); const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
proc.stderr.on("data", (d) => (stderr += d)); let settled = false;
proc.on("close", (code) => { let gotFirstByte = false;
if (code !== 0) {
console.error(`[claude] exit=${code} model=${cliModel} stderr=${stderr.slice(0, 300)}`); function settle(err, result) {
reject(new Error(stderr || stdout || `exit ${code}`)); 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 { } else {
console.log(`[claude] ok model=${cliModel} chars=${stdout.length}`); resolve(result);
resolve(stdout.trim()); }
}
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", reject);
const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT); proc.on("error", (err) => {
proc.on("close", () => clearTimeout(timer)); 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 ──────────────────────────────────────────────────── // ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) { function jsonResponse(res, status, data) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" }); res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} }
@@ -82,25 +456,23 @@ function sendSSE(res, data) {
} }
function streamResponse(res, id, model, content) { function streamResponse(res, id, model, content) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
}); });
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
// Role chunk
sendSSE(res, { sendSSE(res, {
id, object: "chat.completion.chunk", created, model, id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
}); });
// Content chunks (~500 chars each)
for (let i = 0; i < content.length; i += 500) { for (let i = 0; i < content.length; i += 500) {
sendSSE(res, { sendSSE(res, {
id, object: "chat.completion.chunk", created, model, id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }], choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
}); });
} }
// Finish
sendSSE(res, { sendSSE(res, {
id, object: "chat.completion.chunk", created, model, id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }], choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
@@ -131,10 +503,13 @@ async function handleChatCompletions(req, res) {
const model = parsed.model || "claude-sonnet-4-6"; const model = parsed.model || "claude-sonnet-4-6";
const stream = parsed.stream; 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" }); if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
try { try {
const content = await callClaude(model, messages); const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`; const id = `chatcmpl-${randomUUID()}`;
if (stream) { if (stream) {
@@ -144,6 +519,10 @@ async function handleChatCompletions(req, res) {
} }
} catch (err) { } catch (err) {
console.error(`[proxy] error: ${err.message}`); 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" } }); jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
} }
} }
@@ -151,10 +530,19 @@ async function handleChatCompletions(req, res) {
// ── HTTP server ───────────────────────────────────────────────────────── // ── HTTP server ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); 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; } 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 // GET /v1/models
if (req.url === "/v1/models" && req.method === "GET") { if (req.url === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, { return jsonResponse(res, 200, {
@@ -171,20 +559,85 @@ const server = createServer(async (req, res) => {
return handleChatCompletions(req, res); return handleChatCompletions(req, res);
} }
// Health check // GET /health — comprehensive diagnostics
if (req.url === "/health") return jsonResponse(res, 200, { status: "ok" }); 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 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") { if (req.method === "POST") {
return handleChatCompletions(req, res); return handleChatCompletions(req, res);
} }
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" }); jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
}); });
server.listen(PORT, "127.0.0.1", () => { // ── Start ───────────────────────────────────────────────────────────────
console.log(`openclaw-claude-proxy listening on http://127.0.0.1:${PORT}`); 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(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`); 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(`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"', { const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
encoding: "utf-8", encoding: "utf-8",
timeout: 30000, 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(); }).trim();
if (out.length > 0) { if (out.length > 0) {
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`); log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
@@ -279,3 +279,96 @@ if (!SKIP_START && !DRY_RUN) {
execSync(`bash "${startPath}"`, { stdio: "inherit" }); execSync(`bash "${startPath}"`, { stdio: "inherit" });
} catch { /* ignore */ } } 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 #!/bin/bash
# Start claude-proxy if not already running # Start openclaw-claude-proxy if not already running
if ! lsof -i :3456 -sTCP:LISTEN &>/dev/null; then PORT=${CLAUDE_PROXY_PORT:-3456}
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE unset CLAUDECODE
nohup /opt/homebrew/bin/node /Users/taodeng/.openclaw/projects/claude-proxy/server.mjs \ nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/openclaw-claude-proxy/server.mjs" \
>> ~/.openclaw/logs/claude-proxy.log \ >> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
2>> ~/.openclaw/logs/claude-proxy.err.log & 2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
echo "claude-proxy started (pid $!)" echo "claude-proxy started on port $PORT (pid $!)"
else else
echo "claude-proxy already running" echo "claude-proxy already running on port $PORT"
fi 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");