Compare commits

...
15 Commits
Author SHA1 Message Date
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
11 changed files with 1222 additions and 40 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"]
+122 -9
View File
@@ -1,6 +1,16 @@
# 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.
**Why?**
- **$0 API cost** — uses your Claude Pro/Max subscription, not pay-per-token API
- **Zero dependencies** — single Node.js file, no `npm install`
- **One command setup** — `node setup.mjs` handles everything
- **OpenAI-compatible** — standard `/v1/chat/completions` endpoint
- **All Claude models** — Opus 4.6, Sonnet 4.6, Haiku 4
- **Streaming support** — real-time SSE responses
## How it works
@@ -16,14 +26,13 @@ The proxy translates OpenAI-compatible `/v1/chat/completions` requests into `cla
- **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 +41,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 +49,18 @@ openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
openclaw gateway restart
```
## 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 +79,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",
@@ -105,6 +127,38 @@ openclaw gateway restart
| `claude-sonnet-4-6` | sonnet | Good balance of speed/quality |
| `claude-haiku-4` | haiku | Fastest, lightweight |
## 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 (backwards compatible with v1.6.x and earlier).
For local Claude Max / OAuth usage, avoid exporting `ANTHROPIC_API_KEY` globally into the shell that launches the proxy. `v1.7.1` also sanitizes `ANTHROPIC_*` variables before spawning Claude subprocesses to reduce accidental auth-path pollution.
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 (backwards compatible with v1.6.x and earlier).
```bash
# Start with auth enabled
PROXY_API_KEY=my-secret-token node server.mjs
# Configure OpenClaw provider with the matching key
# In openclaw.json, set apiKey under the claude-local provider:
"claude-local": {
"baseUrl": "http://127.0.0.1:3456/v1",
"api": "openai-completions",
"apiKey": "my-secret-token",
...
}
```
The proxy logs auth status on startup: `Auth: enabled (PROXY_API_KEY set)` or `Auth: disabled (no PROXY_API_KEY)`.
## Environment Variables
| Variable | Default | Description |
@@ -112,6 +166,7 @@ openclaw gateway restart
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | `claude` | Path to claude binary |
| `CLAUDE_TIMEOUT` | `120000` | Request timeout (ms) |
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication; when unset, auth is disabled |
## API Endpoints
@@ -119,19 +174,77 @@ openclaw gateway restart
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming)
- `GET /health` — Health check
## Auto-start on Login (macOS)
## Server / Advanced: Docker
For server deployments or if you prefer Docker:
Add to your `~/.zshrc`:
```bash
bash ~/.openclaw/projects/claude-proxy/start.sh 2>/dev/null
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
cd openclaw-claude-proxy
cp .env.example .env # add your CLAUDE_SESSION_TOKEN / CLAUDE_COOKIES
docker compose up -d
```
Or as a single command if you already have a `.env` ready:
```bash
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git && cd openclaw-claude-proxy && docker compose up -d
```
Health check: `curl http://localhost:3456/health`
## 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, follow these steps:
### Quick diagnosis
```bash
# 1. Check if proxy is running
curl http://127.0.0.1:3456/health
# Expected: {"status":"ok"}
# 2. Verify Claude CLI works
claude -p "hello" --model sonnet --output-format text
# Expected: text response
# 3. Verify OpenClaw config
cat ~/.openclaw/openclaw.json | grep -A3 claude-local
```
### Common issues and fixes
| Symptom | Cause | Fix |
|---------|-------|-----|
| Agent doesn't reply, no proxy logs | Gateway didn't load claude-local provider | Check `models.providers.claude-local` in `openclaw.json` |
| Proxy reports `exit 1` | Claude CLI not logged in or token expired | Run `claude login` to re-authenticate |
| `🔑 unknown` in `/status` | Normal — no API key, using OAuth | Does not affect functionality, safe to ignore |
| `/status` shows Context 0% | Messages not reaching proxy (SSE format issue) | Ensure proxy is latest version with streaming support |
| Gateway reports `invalid api type` | OpenClaw renamed API type in new version | Check `api` field is still valid (e.g., `openai-completions`) |
| Proxy startup `EADDRINUSE` | Port 3456 already in use | `lsof -i :3456` to find and kill the old process |
### 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
```
### Pre-upgrade backup (recommended)
```bash
cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak
```
## 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
- The `🔑` field in `/status` shows the configured auth key (or `unknown` if auth is disabled) — this is normal
- 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 same Claude account can be used on multiple machines (shared usage quota)
## License
+7
View File
@@ -0,0 +1,7 @@
services:
claude-proxy:
build: .
ports:
- "3456:3456"
env_file: .env
restart: unless-stopped
+28
View File
@@ -0,0 +1,28 @@
{
"name": "openclaw-claude-proxy",
"version": "1.8.0",
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
"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"
}
}
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* Features:
* - Process pool: pre-spawns CLI processes to eliminate cold start latency
* - SSE streaming + non-streaming responses
* - Concurrent request support
*
* Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: "claude")
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
* CLAUDE_POOL_SIZE — warm process pool size per model (default: 1)
* PROXY_API_KEY — Bearer token for API authentication (optional, if unset auth is disabled)
*/
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);
}
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
const POOL_SIZE = parseInt(process.env.CLAUDE_POOL_SIZE || "1", 10);
const POOL_MAX_IDLE = parseInt(process.env.CLAUDE_POOL_MAX_IDLE || "60000", 10); // max idle time before recycle
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const VERSION = _pkg.version;
const START_TIME = Date.now();
// Model alias mapping: request model → claude CLI --model arg
// Maps both shorthand aliases AND full model IDs to the canonical full model ID
// that the claude CLI accepts. Using short names like "sonnet"/"opus"/"haiku"
// causes the CLI to reject the --model arg and crash immediately.
const MODEL_MAP = {
// Full canonical IDs (pass through as-is)
"claude-opus-4-6": "claude-opus-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
// Short aliases → full canonical IDs
"claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "claude-haiku-4-5-20251001",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"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" },
];
// ── Process Pool ──────────────────────────────────────────────────────────
// Pre-spawns `claude -p` processes that read prompts from stdin.
// When a request arrives, we grab a warm process and pipe the prompt in.
// After the process finishes, a new one is spawned to replace it.
const pool = new Map(); // model → [{ proc, ready }]
// Exponential backoff state per model: tracks consecutive fast failures
// to prevent a tight spawn/die loop when workers crash on startup.
// Delays: 2s base, doubled each failure, capped at 60s.
// After 5 consecutive fast crashes (each lived < 10s, all within 60s),
// the model is marked "degraded" and respawning stops entirely.
const poolBackoff = new Map(); // model → { failures: number, timer: TimeoutId|null, degraded: boolean, windowStart: number }
function spawnWarm(cliModel) {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, [
"-p", "--model", cliModel,
"--output-format", "text",
"--no-session-persistence",
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
], { env, stdio: ["pipe", "pipe", "pipe"] });
const entry = { proc, cliModel, ready: true, spawnedAt: Date.now() };
// Capture stderr from pool workers so crash reasons are visible in logs
let stderrBuf = "";
proc.stderr.on("data", (d) => {
stderrBuf += d;
if (stderrBuf.length > 500) stderrBuf = stderrBuf.slice(-500);
});
proc.on("error", (err) => {
console.error(`[pool] spawn error model=${cliModel}: ${err.message}`);
entry.ready = false;
});
proc.on("exit", (code) => {
const livedMs = Date.now() - entry.spawnedAt;
entry.ready = false;
// Log stderr from crashed pool worker (first 500 chars) to aid debugging
if (stderrBuf.trim()) {
console.error(`[pool] worker stderr model=${cliModel} exit=${code} lived=${livedMs}ms: ${stderrBuf.slice(0, 500)}`);
}
// If the process survived > 10s, it was healthy — reset the backoff counter and window
if (livedMs > 10000) {
const state = poolBackoff.get(cliModel);
if (state && (state.failures > 0 || state.degraded)) {
console.log(`[pool] resetting backoff for model=${cliModel} (lived ${livedMs}ms)`);
state.failures = 0;
state.degraded = false;
state.windowStart = Date.now();
}
}
// Remove from pool
const arr = pool.get(cliModel);
if (arr) {
const idx = arr.indexOf(entry);
if (idx !== -1) arr.splice(idx, 1);
}
// Replenish: treat as crash (apply backoff) only if it died fast (< 10s)
const isCrash = livedMs <= 10000;
replenishPool(cliModel, isCrash);
});
return entry;
}
// Recycle idle processes to prevent stale connections
function recycleStaleProcesses() {
const now = Date.now();
for (const [cliModel, arr] of pool) {
for (const entry of arr) {
if (entry.ready && (now - entry.spawnedAt) > POOL_MAX_IDLE) {
console.log(`[pool] recycling stale process model=${cliModel} (idle ${Math.round((now - entry.spawnedAt) / 1000)}s)`);
entry.ready = false;
entry.proc.kill();
// exit handler will replenish
}
}
}
}
setInterval(recycleStaleProcesses, 15000); // check every 15s
const BACKOFF_BASE_MS = 2000; // 2s starting delay
const BACKOFF_MAX_MS = 60000; // 60s ceiling
const CRASH_LIMIT = 5; // max consecutive fast crashes before degraded
const CRASH_WINDOW_MS = 60000; // window for counting consecutive fast crashes (60s)
// replenishPool(cliModel, isCrash)
// isCrash=false → initial or manual fill, no backoff applied
// isCrash=true → called from exit handler after a fast crash
function replenishPool(cliModel, isCrash = false) {
if (!pool.has(cliModel)) pool.set(cliModel, []);
if (!poolBackoff.has(cliModel)) poolBackoff.set(cliModel, { failures: 0, timer: null, degraded: false, windowStart: Date.now() });
const arr = pool.get(cliModel);
const state = poolBackoff.get(cliModel);
// If this model is degraded (too many consecutive fast crashes), stop respawning
if (state.degraded) {
console.error(`[pool] DEGRADED: model=${cliModel} will not be respawned. Restart the proxy to retry.`);
return;
}
const alive = arr.filter((e) => e.ready).length;
const needed = POOL_SIZE - alive;
if (needed <= 0) return;
// Cancel any pending backoff timer for this model before scheduling a new one
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
// Only track failures and apply backoff when this is a crash respawn
if (!isCrash) {
// Immediate spawn — no backoff on initial fill or manual replenish
const currentAlive = arr.filter((e) => e.ready).length;
const currentNeeded = POOL_SIZE - currentAlive;
for (let i = 0; i < currentNeeded; i++) {
const entry = spawnWarm(cliModel);
arr.push(entry);
console.log(`[pool] pre-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length})`);
}
return;
}
// --- Crash path: apply exponential backoff and degraded-state logic ---
const now = Date.now();
// Reset window if the last crash was outside the rolling window
if ((now - state.windowStart) > CRASH_WINDOW_MS) {
state.windowStart = now;
state.failures = 0;
}
state.failures += 1;
// Check if we've hit the crash limit within the rolling window
if (state.failures >= CRASH_LIMIT) {
state.degraded = true;
console.error(
`[pool] DEGRADED: model=${cliModel} crashed ${state.failures} times in ` +
`${Math.round((now - state.windowStart) / 1000)}s. ` +
`Stopping respawn to prevent CPU spin. Restart the proxy to retry.`
);
return;
}
// Exponential backoff: 2s, 4s, 8s, 16s, 32s … capped at 60s
const delayMs = Math.min(BACKOFF_BASE_MS * Math.pow(2, state.failures - 1), BACKOFF_MAX_MS);
console.warn(`[pool] backoff model=${cliModel} delay=${delayMs}ms (failures=${state.failures}/${CRASH_LIMIT})`);
state.timer = setTimeout(() => {
state.timer = null;
const currentAlive = arr.filter((e) => e.ready).length;
const currentNeeded = POOL_SIZE - currentAlive;
for (let i = 0; i < currentNeeded; i++) {
const entry = spawnWarm(cliModel);
arr.push(entry);
console.log(`[pool] re-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length}, failures=${state.failures}/${CRASH_LIMIT})`);
}
}, delayMs);
}
function getWarmProcess(cliModel) {
const arr = pool.get(cliModel) || [];
const entry = arr.find((e) => e.ready);
if (entry) {
entry.ready = false; // mark as in-use
const warmMs = Date.now() - entry.spawnedAt;
console.log(`[pool] using warm process model=${cliModel} (warm for ${warmMs}ms)`);
return entry.proc;
}
return null;
}
// Initialize pool for all models
function initPool() {
for (const cliModel of new Set(Object.values(MODEL_MAP))) {
replenishPool(cliModel);
}
}
// ── 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");
const cliModel = MODEL_MAP[model] || model;
// Try to use a warm process from the pool
let proc = getWarmProcess(cliModel);
let usedPool = !!proc;
if (!proc) {
// Cold start fallback: spawn fresh
console.log(`[pool] no warm process for model=${cliModel}, cold starting...`);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
proc = spawn(CLAUDE, [
"-p", "--model", cliModel,
"--output-format", "text",
"--no-session-persistence",
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
"--", prompt,
], { env, stdio: ["ignore", "pipe", "pipe"] });
}
let stdout = "";
let stderr = "";
const t0 = Date.now();
proc.stdout.on("data", (d) => (stdout += d));
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code) => {
const elapsed = Date.now() - t0;
if (code !== 0) {
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 300)}`);
reject(new Error(stderr || stdout || `exit ${code}`));
} else {
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms pool=${usedPool}`);
resolve(stdout.trim());
}
});
proc.on("error", reject);
// Log prompt size for debugging
console.log(`[claude] request model=${cliModel} prompt_chars=${prompt.length} pool=${usedPool}`);
// If using pool process, send prompt via stdin
if (usedPool) {
proc.stdin.write(prompt);
proc.stdin.end();
}
const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT);
proc.on("close", () => clearTimeout(timer));
});
}
// ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) {
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) {
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",
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;
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
try {
const content = await callClaude(model, messages);
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, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
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 — includes pool status, version, uptime
if (req.url === "/health") {
const poolStatus = {};
for (const [model, arr] of pool) {
const readyCount = arr.filter(e => e.ready).length;
const errorCount = arr.filter(e => !e.ready).length;
poolStatus[model] = {
total: arr.length,
ready: readyCount,
error: errorCount,
status: readyCount > 0 ? "ready" : "error",
};
}
const uptimeMs = Date.now() - START_TIME;
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
return jsonResponse(res, 200, {
status: binaryOk ? "ok" : "degraded",
version: VERSION,
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
pool: poolStatus,
});
}
// Catch-all: try to handle any POST with messages
if (req.method === "POST") {
return handleChatCompletions(req, res);
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
});
// ── Start ──────────────────────────────────────────────────────────────
initPool();
server.listen(PORT, "0.0.0.0", () => {
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms`);
console.log(`Pool size: ${POOL_SIZE} per model, max idle: ${POOL_MAX_IDLE / 1000}s`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
});
+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": "1.8.0",
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
"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"
+351 -20
View File
@@ -5,35 +5,294 @@
* 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.
* Features:
* - Process pool: pre-spawns CLI processes to eliminate cold start latency
* - SSE streaming + non-streaming responses
* - Concurrent request support
*
* Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: "claude")
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
* CLAUDE_POOL_SIZE — warm process pool size per model (default: 1)
* PROXY_API_KEY — Bearer token for API authentication (optional, if unset auth is disabled)
*/
import { createServer } from "node:http";
import { spawn } from "node:child_process";
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);
}
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 POOL_SIZE = parseInt(process.env.CLAUDE_POOL_SIZE || "1", 10);
const POOL_MAX_IDLE = parseInt(process.env.CLAUDE_POOL_MAX_IDLE || "60000", 10); // max idle time before recycle
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const VERSION = _pkg.version;
const START_TIME = Date.now();
// Model alias mapping: request model → claude CLI --model arg
// Maps both shorthand aliases AND full model IDs to the canonical full model ID
// that the claude CLI accepts. Using short names like "sonnet"/"opus"/"haiku"
// causes the CLI to reject the --model arg and crash immediately.
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",
// Full canonical IDs (pass through as-is)
"claude-opus-4-6": "claude-opus-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
// Short aliases → full canonical IDs
"claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "claude-haiku-4-5-20251001",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"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" },
];
// ── Process Pool ──────────────────────────────────────────────────────────
// Pre-spawns `claude -p` processes that read prompts from stdin.
// When a request arrives, we grab a warm process and pipe the prompt in.
// After the process finishes, a new one is spawned to replace it.
const pool = new Map(); // model → [{ proc, ready }]
// Exponential backoff state per model: tracks consecutive fast failures
// to prevent a tight spawn/die loop when workers crash on startup.
// Delays: 2s base, doubled each failure, capped at 60s.
// After 5 consecutive fast crashes (each lived < 10s, all within 60s),
// the model is marked "degraded" and respawning stops entirely.
const poolBackoff = new Map(); // model → { failures: number, timer: TimeoutId|null, degraded: boolean, windowStart: number }
function spawnWarm(cliModel) {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, [
"-p", "--model", cliModel,
"--output-format", "text",
"--no-session-persistence",
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
], { env, stdio: ["pipe", "pipe", "pipe"] });
const entry = { proc, cliModel, ready: true, spawnedAt: Date.now() };
// Capture stderr from pool workers so crash reasons are visible in logs
let stderrBuf = "";
proc.stderr.on("data", (d) => {
stderrBuf += d;
if (stderrBuf.length > 500) stderrBuf = stderrBuf.slice(-500);
});
proc.on("error", (err) => {
console.error(`[pool] spawn error model=${cliModel}: ${err.message}`);
entry.ready = false;
});
proc.on("exit", (code) => {
const livedMs = Date.now() - entry.spawnedAt;
entry.ready = false;
// Log stderr from crashed pool worker (first 500 chars) to aid debugging
if (stderrBuf.trim()) {
console.error(`[pool] worker stderr model=${cliModel} exit=${code} lived=${livedMs}ms: ${stderrBuf.slice(0, 500)}`);
}
// If the process survived > 10s, it was healthy — reset the backoff counter and window
if (livedMs > 10000) {
const state = poolBackoff.get(cliModel);
if (state && (state.failures > 0 || state.degraded)) {
console.log(`[pool] resetting backoff for model=${cliModel} (lived ${livedMs}ms)`);
state.failures = 0;
state.degraded = false;
state.windowStart = Date.now();
}
}
// Remove from pool
const arr = pool.get(cliModel);
if (arr) {
const idx = arr.indexOf(entry);
if (idx !== -1) arr.splice(idx, 1);
}
// Replenish: treat as crash (apply backoff) only if it died fast (< 10s)
const isCrash = livedMs <= 10000;
replenishPool(cliModel, isCrash);
});
return entry;
}
// Recycle idle processes to prevent stale connections
function recycleStaleProcesses() {
const now = Date.now();
for (const [cliModel, arr] of pool) {
for (const entry of arr) {
if (entry.ready && (now - entry.spawnedAt) > POOL_MAX_IDLE) {
console.log(`[pool] recycling stale process model=${cliModel} (idle ${Math.round((now - entry.spawnedAt) / 1000)}s)`);
entry.ready = false;
entry.proc.kill();
// exit handler will replenish
}
}
}
}
setInterval(recycleStaleProcesses, 15000); // check every 15s
const BACKOFF_BASE_MS = 2000; // 2s starting delay
const BACKOFF_MAX_MS = 60000; // 60s ceiling
const CRASH_LIMIT = 5; // max consecutive fast crashes before degraded
const CRASH_WINDOW_MS = 60000; // window for counting consecutive fast crashes (60s)
// replenishPool(cliModel, isCrash)
// isCrash=false → initial or manual fill, no backoff applied
// isCrash=true → called from exit handler after a fast crash
function replenishPool(cliModel, isCrash = false) {
if (!pool.has(cliModel)) pool.set(cliModel, []);
if (!poolBackoff.has(cliModel)) poolBackoff.set(cliModel, { failures: 0, timer: null, degraded: false, windowStart: Date.now() });
const arr = pool.get(cliModel);
const state = poolBackoff.get(cliModel);
// If this model is degraded (too many consecutive fast crashes), stop respawning
if (state.degraded) {
console.error(`[pool] DEGRADED: model=${cliModel} will not be respawned. Restart the proxy to retry.`);
return;
}
const alive = arr.filter((e) => e.ready).length;
const needed = POOL_SIZE - alive;
if (needed <= 0) return;
// Cancel any pending backoff timer for this model before scheduling a new one
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
// Only track failures and apply backoff when this is a crash respawn
if (!isCrash) {
// Immediate spawn — no backoff on initial fill or manual replenish
const currentAlive = arr.filter((e) => e.ready).length;
const currentNeeded = POOL_SIZE - currentAlive;
for (let i = 0; i < currentNeeded; i++) {
const entry = spawnWarm(cliModel);
arr.push(entry);
console.log(`[pool] pre-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length})`);
}
return;
}
// --- Crash path: apply exponential backoff and degraded-state logic ---
const now = Date.now();
// Reset window if the last crash was outside the rolling window
if ((now - state.windowStart) > CRASH_WINDOW_MS) {
state.windowStart = now;
state.failures = 0;
}
state.failures += 1;
// Check if we've hit the crash limit within the rolling window
if (state.failures >= CRASH_LIMIT) {
state.degraded = true;
console.error(
`[pool] DEGRADED: model=${cliModel} crashed ${state.failures} times in ` +
`${Math.round((now - state.windowStart) / 1000)}s. ` +
`Stopping respawn to prevent CPU spin. Restart the proxy to retry.`
);
return;
}
// Exponential backoff: 2s, 4s, 8s, 16s, 32s … capped at 60s
const delayMs = Math.min(BACKOFF_BASE_MS * Math.pow(2, state.failures - 1), BACKOFF_MAX_MS);
console.warn(`[pool] backoff model=${cliModel} delay=${delayMs}ms (failures=${state.failures}/${CRASH_LIMIT})`);
state.timer = setTimeout(() => {
state.timer = null;
const currentAlive = arr.filter((e) => e.ready).length;
const currentNeeded = POOL_SIZE - currentAlive;
for (let i = 0; i < currentNeeded; i++) {
const entry = spawnWarm(cliModel);
arr.push(entry);
console.log(`[pool] re-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length}, failures=${state.failures}/${CRASH_LIMIT})`);
}
}, delayMs);
}
function getWarmProcess(cliModel) {
const arr = pool.get(cliModel) || [];
const entry = arr.find((e) => e.ready);
if (entry) {
entry.ready = false; // mark as in-use
const warmMs = Date.now() - entry.spawnedAt;
console.log(`[pool] using warm process model=${cliModel} (warm for ${warmMs}ms)`);
return entry.proc;
}
return null;
}
// Initialize pool for all models
function initPool() {
for (const cliModel of new Set(Object.values(MODEL_MAP))) {
replenishPool(cliModel);
}
}
// ── Call claude CLI ─────────────────────────────────────────────────────
function callClaude(model, messages) {
return new Promise((resolve, reject) => {
@@ -47,25 +306,55 @@ function callClaude(model, messages) {
.join("\n\n");
const cliModel = MODEL_MAP[model] || model;
const args = ["-p", "--model", cliModel, "--output-format", "text", "--no-session-persistence", "--", prompt];
const env = { ...process.env };
delete env.CLAUDECODE;
// Try to use a warm process from the pool
let proc = getWarmProcess(cliModel);
let usedPool = !!proc;
if (!proc) {
// Cold start fallback: spawn fresh
console.log(`[pool] no warm process for model=${cliModel}, cold starting...`);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
proc = spawn(CLAUDE, [
"-p", "--model", cliModel,
"--output-format", "text",
"--no-session-persistence",
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
"--", prompt,
], { env, stdio: ["ignore", "pipe", "pipe"] });
}
let stdout = "";
let stderr = "";
const proc = spawn(CLAUDE, args, { env, stdio: ["ignore", "pipe", "pipe"] });
const t0 = Date.now();
proc.stdout.on("data", (d) => (stdout += d));
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code) => {
const elapsed = Date.now() - t0;
if (code !== 0) {
console.error(`[claude] exit=${code} model=${cliModel} stderr=${stderr.slice(0, 300)}`);
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 300)}`);
reject(new Error(stderr || stdout || `exit ${code}`));
} else {
console.log(`[claude] ok model=${cliModel} chars=${stdout.length}`);
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms pool=${usedPool}`);
resolve(stdout.trim());
}
});
proc.on("error", reject);
// Log prompt size for debugging
console.log(`[claude] request model=${cliModel} prompt_chars=${prompt.length} pool=${usedPool}`);
// If using pool process, send prompt via stdin
if (usedPool) {
proc.stdin.write(prompt);
proc.stdin.end();
}
const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT);
proc.on("close", () => clearTimeout(timer));
});
@@ -144,6 +433,10 @@ async function handleChatCompletions(req, res) {
}
} 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" } });
}
}
@@ -155,6 +448,15 @@ const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
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, {
@@ -171,8 +473,32 @@ const server = createServer(async (req, res) => {
return handleChatCompletions(req, res);
}
// Health check
if (req.url === "/health") return jsonResponse(res, 200, { status: "ok" });
// GET /health — includes pool status, version, uptime
if (req.url === "/health") {
const poolStatus = {};
for (const [model, arr] of pool) {
const readyCount = arr.filter(e => e.ready).length;
const errorCount = arr.filter(e => !e.ready).length;
poolStatus[model] = {
total: arr.length,
ready: readyCount,
error: errorCount,
status: readyCount > 0 ? "ready" : "error",
};
}
const uptimeMs = Date.now() - START_TIME;
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
return jsonResponse(res, 200, {
status: binaryOk ? "ok" : "degraded",
version: VERSION,
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
pool: poolStatus,
});
}
// Catch-all: try to handle any POST with messages
if (req.method === "POST") {
@@ -182,9 +508,14 @@ const server = createServer(async (req, res) => {
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`openclaw-claude-proxy listening on http://127.0.0.1:${PORT}`);
// ── Start ──────────────────────────────────────────────────────────────
initPool();
server.listen(PORT, "0.0.0.0", () => {
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms`);
console.log(`Pool size: ${POOL_SIZE} per model, max idle: ${POOL_MAX_IDLE / 1000}s`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
});
+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");