mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
# 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
|
## 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`)
|
- **Claude CLI** installed and authenticated (`claude login`)
|
||||||
- **OpenClaw** installed
|
- **OpenClaw** installed
|
||||||
|
|
||||||
## Quick Install
|
## Quick Start (Node.js)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone
|
|
||||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
||||||
cd openclaw-claude-proxy
|
cd openclaw-claude-proxy
|
||||||
|
|
||||||
# Auto-configure OpenClaw + start proxy
|
# Auto-configure OpenClaw + start proxy + install auto-start
|
||||||
node setup.mjs
|
node setup.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,6 +41,7 @@ That's it. The setup script will:
|
|||||||
2. Add `claude-local` provider to `openclaw.json`
|
2. Add `claude-local` provider to `openclaw.json`
|
||||||
3. Add auth profiles to all agents
|
3. Add auth profiles to all agents
|
||||||
4. Start the proxy
|
4. Start the proxy
|
||||||
|
5. Install auto-start on login (launchd on macOS, systemd on Linux)
|
||||||
|
|
||||||
Then set your preferred Claude model as default:
|
Then set your preferred Claude model as default:
|
||||||
```bash
|
```bash
|
||||||
@@ -39,6 +49,18 @@ openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
|
|||||||
openclaw gateway restart
|
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
|
## Manual Install
|
||||||
|
|
||||||
### 1. Start the proxy
|
### 1. Start the proxy
|
||||||
@@ -57,7 +79,7 @@ Add to `~/.openclaw/openclaw.json` under `models.providers`:
|
|||||||
"claude-local": {
|
"claude-local": {
|
||||||
"baseUrl": "http://127.0.0.1:3456/v1",
|
"baseUrl": "http://127.0.0.1:3456/v1",
|
||||||
"api": "openai-completions",
|
"api": "openai-completions",
|
||||||
"authHeader": false,
|
"apiKey": "<your PROXY_API_KEY, or omit if auth disabled>",
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-6",
|
"id": "claude-opus-4-6",
|
||||||
@@ -105,6 +127,30 @@ openclaw gateway restart
|
|||||||
| `claude-sonnet-4-6` | sonnet | Good balance of speed/quality |
|
| `claude-sonnet-4-6` | sonnet | Good balance of speed/quality |
|
||||||
| `claude-haiku-4` | haiku | Fastest, lightweight |
|
| `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).
|
||||||
|
|
||||||
|
```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
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -112,6 +158,7 @@ openclaw gateway restart
|
|||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||||
| `CLAUDE_BIN` | `claude` | Path to claude binary |
|
| `CLAUDE_BIN` | `claude` | Path to claude binary |
|
||||||
| `CLAUDE_TIMEOUT` | `120000` | Request timeout (ms) |
|
| `CLAUDE_TIMEOUT` | `120000` | Request timeout (ms) |
|
||||||
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication; when unset, auth is disabled |
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -119,19 +166,77 @@ openclaw gateway restart
|
|||||||
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming)
|
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming)
|
||||||
- `GET /health` — Health check
|
- `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
|
||||||
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
|
## Notes
|
||||||
|
|
||||||
- Cost shows as $0 because billing goes through your Claude subscription
|
- Cost shows as $0 because billing goes through your Claude subscription
|
||||||
- The `🔑` field in `/status` may show the dummy auth key — this is normal
|
- 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
|
- Each request spawns a `claude -p` process; concurrent requests are supported
|
||||||
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
|
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
|
||||||
|
- The same Claude account can be used on multiple machines (shared usage quota)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
claude-proxy:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3456:3456"
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "1.0.0",
|
"version": "1.7.0",
|
||||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI — use your Claude Pro/Max subscription as an OpenClaw model provider",
|
"description": "OpenAI-compatible proxy that routes requests through Claude CLI — use your Claude Pro/Max subscription as an OpenClaw model provider",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+298
-18
@@ -5,35 +5,254 @@
|
|||||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||||
*
|
*
|
||||||
* Supports both streaming (SSE) and non-streaming responses.
|
* Features:
|
||||||
|
* - Process pool: pre-spawns CLI processes to eliminate cold start latency
|
||||||
|
* - SSE streaming + non-streaming responses
|
||||||
|
* - Concurrent request support
|
||||||
*
|
*
|
||||||
* Env vars:
|
* Env vars:
|
||||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||||
* CLAUDE_BIN — path to claude binary (default: "claude")
|
* CLAUDE_BIN — path to claude binary (default: "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 { createServer } from "node:http";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFileSync } 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"));
|
||||||
|
|
||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||||
const CLAUDE = process.env.CLAUDE_BIN || "claude";
|
const CLAUDE = process.env.CLAUDE_BIN || "claude";
|
||||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
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
|
// 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 = {
|
const MODEL_MAP = {
|
||||||
"claude-opus-4-6": "opus",
|
// Full canonical IDs (pass through as-is)
|
||||||
"claude-opus-4": "opus",
|
"claude-opus-4-6": "claude-opus-4-6",
|
||||||
"claude-sonnet-4-6": "sonnet",
|
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||||
"claude-sonnet-4": "sonnet",
|
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
|
||||||
"claude-haiku-4": "haiku",
|
// 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 = [
|
const MODELS = [
|
||||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||||
{ id: "claude-haiku-4", name: "Claude Haiku 4" },
|
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────
|
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||||
function callClaude(model, messages) {
|
function callClaude(model, messages) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -47,25 +266,52 @@ function callClaude(model, messages) {
|
|||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
const cliModel = MODEL_MAP[model] || model;
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
const args = ["-p", "--model", cliModel, "--output-format", "text", "--no-session-persistence", "--", prompt];
|
|
||||||
const env = { ...process.env };
|
// Try to use a warm process from the pool
|
||||||
delete env.CLAUDECODE;
|
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;
|
||||||
|
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 stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const proc = spawn(CLAUDE, args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
const t0 = Date.now();
|
||||||
|
|
||||||
proc.stdout.on("data", (d) => (stdout += d));
|
proc.stdout.on("data", (d) => (stdout += d));
|
||||||
proc.stderr.on("data", (d) => (stderr += d));
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code) => {
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
if (code !== 0) {
|
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}`));
|
reject(new Error(stderr || stdout || `exit ${code}`));
|
||||||
} else {
|
} 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());
|
resolve(stdout.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
proc.on("error", reject);
|
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);
|
const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT);
|
||||||
proc.on("close", () => clearTimeout(timer));
|
proc.on("close", () => clearTimeout(timer));
|
||||||
});
|
});
|
||||||
@@ -155,6 +401,15 @@ const server = createServer(async (req, res) => {
|
|||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||||
|
|
||||||
|
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
|
||||||
|
if (PROXY_API_KEY && req.url !== "/health") {
|
||||||
|
const auth = req.headers["authorization"] || "";
|
||||||
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||||
|
if (token !== PROXY_API_KEY) {
|
||||||
|
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /v1/models
|
// GET /v1/models
|
||||||
if (req.url === "/v1/models" && req.method === "GET") {
|
if (req.url === "/v1/models" && req.method === "GET") {
|
||||||
return jsonResponse(res, 200, {
|
return jsonResponse(res, 200, {
|
||||||
@@ -171,8 +426,28 @@ const server = createServer(async (req, res) => {
|
|||||||
return handleChatCompletions(req, res);
|
return handleChatCompletions(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check
|
// GET /health — includes pool status, version, uptime
|
||||||
if (req.url === "/health") return jsonResponse(res, 200, { status: "ok" });
|
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;
|
||||||
|
return jsonResponse(res, 200, {
|
||||||
|
status: "ok",
|
||||||
|
version: VERSION,
|
||||||
|
uptime: uptimeMs,
|
||||||
|
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`,
|
||||||
|
pool: poolStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Catch-all: try to handle any POST with messages
|
// Catch-all: try to handle any POST with messages
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
@@ -182,9 +457,14 @@ const server = createServer(async (req, res) => {
|
|||||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
|
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, "127.0.0.1", () => {
|
// ── Start ──────────────────────────────────────────────────────────────
|
||||||
console.log(`openclaw-claude-proxy listening on http://127.0.0.1:${PORT}`);
|
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(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||||
console.log(`Claude binary: ${CLAUDE}`);
|
console.log(`Claude binary: ${CLAUDE}`);
|
||||||
console.log(`Timeout: ${TIMEOUT}ms`);
|
console.log(`Timeout: ${TIMEOUT}ms`);
|
||||||
|
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)"}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -279,3 +279,96 @@ if (!SKIP_START && !DRY_RUN) {
|
|||||||
execSync(`bash "${startPath}"`, { stdio: "inherit" });
|
execSync(`bash "${startPath}"`, { stdio: "inherit" });
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
console.log("\n🔄 Installing auto-start on login...\n");
|
||||||
|
|
||||||
|
const platform = process.platform;
|
||||||
|
const nodeBin = process.execPath;
|
||||||
|
|
||||||
|
// Ensure logs dir exists
|
||||||
|
const logsDir = join(OPENCLAW_DIR, "logs");
|
||||||
|
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
if (platform === "darwin") {
|
||||||
|
// macOS: launchd
|
||||||
|
const plistDir = join(HOME, "Library", "LaunchAgents");
|
||||||
|
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
|
||||||
|
|
||||||
|
const plistPath = join(plistDir, "ai.openclaw.proxy.plist");
|
||||||
|
const logPath = join(logsDir, "proxy.log");
|
||||||
|
|
||||||
|
const plistXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>ai.openclaw.proxy</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${nodeBin}</string>
|
||||||
|
<string>${serverPath}</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>CLAUDE_PROXY_PORT</key>
|
||||||
|
<string>${PORT}</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>${logPath}</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>${logPath}</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`;
|
||||||
|
|
||||||
|
writeFileSync(plistPath, plistXml);
|
||||||
|
log(`Plist written: ${plistPath}`);
|
||||||
|
|
||||||
|
// Unload first (in case it was already loaded) then load
|
||||||
|
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||||
|
execSync(`launchctl load "${plistPath}"`);
|
||||||
|
log(`launchctl loaded ai.openclaw.proxy`);
|
||||||
|
|
||||||
|
} else if (platform === "linux") {
|
||||||
|
// Linux: systemd user service
|
||||||
|
const systemdDir = join(HOME, ".config", "systemd", "user");
|
||||||
|
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
|
||||||
|
|
||||||
|
const servicePath = join(systemdDir, "openclaw-proxy.service");
|
||||||
|
const logPath = join(logsDir, "proxy.log");
|
||||||
|
|
||||||
|
const serviceUnit = `[Unit]
|
||||||
|
Description=OpenClaw Claude Proxy
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=${nodeBin} ${serverPath}
|
||||||
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||||
|
Restart=always
|
||||||
|
StandardOutput=append:${logPath}
|
||||||
|
StandardError=append:${logPath}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
`;
|
||||||
|
|
||||||
|
writeFileSync(servicePath, serviceUnit);
|
||||||
|
log(`Service file written: ${servicePath}`);
|
||||||
|
|
||||||
|
execSync(`systemctl --user daemon-reload`);
|
||||||
|
execSync(`systemctl --user enable openclaw-proxy`);
|
||||||
|
execSync(`systemctl --user start openclaw-proxy`);
|
||||||
|
log(`systemd user service enabled and started`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
warn(`Auto-start not supported on ${platform} — start manually with: bash ${startPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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