mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a22372e28 | ||
|
|
917ff60014 | ||
|
|
5f00d6960b | ||
|
|
2fa7991d6e | ||
|
|
ff05657c94 | ||
|
|
17384acb4c | ||
|
|
37803edb3f | ||
|
|
43d4599dac | ||
|
|
6ff48f68c0 | ||
|
|
857c703b0a | ||
|
|
606fc255e2 | ||
|
|
6d708a602f | ||
|
|
57df919873 | ||
|
|
15fee08b32 | ||
|
|
92c55df021 |
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
scripts/
|
||||
start.sh
|
||||
+14
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
claude-proxy:
|
||||
build: .
|
||||
ports:
|
||||
- "3456:3456"
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
+349
-18
@@ -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];
|
||||
|
||||
// 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)"}`);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user