mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7434f6adc0 | ||
|
|
8a7951134e | ||
|
|
47434a8ba7 | ||
|
|
1d95dbeebc | ||
|
|
9e6bef729b | ||
|
|
256b2bfb77 | ||
|
|
384e66bfa6 | ||
|
|
76a8c56c88 |
@@ -4,6 +4,40 @@
|
||||
|
||||
A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run.
|
||||
|
||||
## v2.5.0 — Emergency Fix: Sliding-Window Circuit Breaker
|
||||
|
||||
**Incident (2026-03-22):** Multi-agent burst (ClawTeam with 5+ Opus agents) caused cascading timeout failure. The old consecutive-count circuit breaker (threshold=3) tripped within seconds, blocking ALL requests globally — including unrelated agents and new sessions. With fallback models removed, this resulted in complete service outage ("LLM request timed out." on every message).
|
||||
|
||||
**Root cause:** v2.4.0's circuit breaker counted consecutive failures per model. When ClawTeam spawned multiple concurrent Opus agents and Claude API had moderate latency, 3 quick timeouts opened the breaker for the entire model. With `fallbacks: []`, the gateway had no alternative path.
|
||||
|
||||
**What's new in v2.5.0:**
|
||||
- **Sliding-window circuit breaker** — counts failures in a 5-minute window (default: 6 failures) instead of 3 consecutive. Multi-agent bursts no longer trip the breaker instantly.
|
||||
- **Graduated backoff** — cooldown doubles on each re-open (120s → 240s → 300s cap), resets fully on first success. Prevents oscillation between open/half-open during extended API issues.
|
||||
- **Multi-probe half-open** — allows 2 concurrent probe requests in half-open state (was 1), improving recovery speed.
|
||||
- **Increased default timeouts** — Opus first-byte 60s→90s, Sonnet 45s→60s, overall 120s→300s, max concurrent 5→8. Designed for large agent system prompts (30K+ chars).
|
||||
- **Health endpoint shows breaker state** — `/health` now exposes per-model breaker state (window failures, cooldown, reopen count). Status is "degraded" when any breaker is open.
|
||||
|
||||
**New env vars:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting |
|
||||
| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state |
|
||||
|
||||
**Updated defaults:**
|
||||
|
||||
| Variable | Old Default | New Default |
|
||||
|----------|-------------|-------------|
|
||||
| `CLAUDE_TIMEOUT` | `120000` | `300000` |
|
||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `45000` | `90000` |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `5` | `8` |
|
||||
| `CLAUDE_BREAKER_THRESHOLD` | `3` | `6` |
|
||||
| `CLAUDE_BREAKER_COOLDOWN` | `60000` | `120000` |
|
||||
|
||||
**Upgrade:** Pull latest and restart the proxy. The new defaults take effect immediately. If you have custom env vars set in your plist/service file, review and adjust them.
|
||||
|
||||
---
|
||||
|
||||
## v2.0.0 — Major Upgrade
|
||||
|
||||
**What's new:**
|
||||
@@ -174,13 +208,18 @@ openclaw gateway restart
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||
| `CLAUDE_TIMEOUT` | `300000` | Request timeout (ms) |
|
||||
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
|
||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms), adaptive by model tier |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Set `true` to bypass all permission checks |
|
||||
| `CLAUDE_SYSTEM_PROMPT` | *(empty)* | System prompt appended to all requests |
|
||||
| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON file |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry in ms (default: 1 hour) |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent claude processes |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||
| `CLAUDE_BREAKER_THRESHOLD` | `6` | Failures in window before circuit opens |
|
||||
| `CLAUDE_BREAKER_COOLDOWN` | `120000` | Base cooldown (ms) before half-open (graduates on re-open) |
|
||||
| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting |
|
||||
| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# openclaw-claude-proxy v2.3.0
|
||||
|
||||
Use your **Claude Pro / Max** subscription as an **OpenAI-compatible local endpoint**.
|
||||
|
||||
`openclaw-claude-proxy` accepts OpenAI-style chat completion requests, then runs them through the local `claude` CLI. That means tools which only know how to talk to an OpenAI API can still use Claude models through a local base URL.
|
||||
|
||||
## Why v2 matters
|
||||
|
||||
v2 is not just a bugfix release. It changes the runtime model:
|
||||
|
||||
- **On-demand spawning** instead of fragile warm pools
|
||||
- **Session resume** support for multi-turn conversations
|
||||
- **Faster fallback** with first-byte timeout + lower default request timeout
|
||||
- **Full tool access** via configurable allowed tools
|
||||
- **MCP config + system prompt pass-through**
|
||||
- **Health / sessions / diagnostics endpoints**
|
||||
- **Safe coexistence with Claude Code channel / interactive mode**
|
||||
|
||||
## The short pitch
|
||||
|
||||
If Claude's new channel workflow feels useful, OCP v2 now covers the same practical ground for many local agent/tooling setups:
|
||||
|
||||
- multi-turn continuity
|
||||
- tool-enabled Claude runs
|
||||
- local orchestration
|
||||
- stable process isolation
|
||||
- coexistence with your normal Claude Code workflow
|
||||
|
||||
And it adds a few advantages that channel users usually still want:
|
||||
|
||||
- **OpenAI-compatible HTTP API** for existing tools
|
||||
- **Works with OpenClaw, Cursor, Continue, Open WebUI, LangChain, and anything with custom base URL support**
|
||||
- **Explicit health checks and diagnostics**
|
||||
- **Model/provider failover can happen outside Claude itself**
|
||||
- **No lock-in to a single client UX**
|
||||
|
||||
## Coexistence with Claude Code channel
|
||||
|
||||
This is the important part: **OCP v2 does not replace Claude Code channel, and it does not need to. They can coexist on the same machine.**
|
||||
|
||||
### Claude Code channel / interactive mode
|
||||
- persistent interactive workflow
|
||||
- MCP protocol / in-process experience
|
||||
- great when you are directly driving Claude Code
|
||||
|
||||
### OCP v2
|
||||
- local HTTP server on `localhost`
|
||||
- OpenAI-compatible API surface
|
||||
- per-request `claude -p` execution with session resume when you want continuity
|
||||
- ideal for external tools, routers, orchestrators, OpenClaw providers, and local automation
|
||||
|
||||
### Practical takeaway
|
||||
Use both:
|
||||
- use **Claude Code channel** when you want Claude's native interactive workflow
|
||||
- use **OCP v2** when another app expects an OpenAI-style API but you still want to use Claude
|
||||
|
||||
They solve adjacent problems, not identical ones.
|
||||
|
||||
## Unique advantages of OCP v2
|
||||
|
||||
1. **API compatibility**
|
||||
- Drop into tools that already support OpenAI-compatible endpoints.
|
||||
- No need to wait for each tool to add native Claude channel support.
|
||||
|
||||
2. **Routing freedom**
|
||||
- Put OCP behind OpenClaw or another router.
|
||||
- Mix Claude with fallback providers outside the Claude client itself.
|
||||
|
||||
3. **Operational visibility**
|
||||
- `/health`, `/sessions`, recent errors, auth state, resolved binary path, timeout config.
|
||||
- Much easier to debug than a black-box local integration.
|
||||
|
||||
4. **Safer runtime model**
|
||||
- v2 removes the old pre-spawn pool crash loop.
|
||||
- No stale workers, no degraded warm pool states, fewer hidden failure modes.
|
||||
|
||||
5. **Configurable tools and behavior**
|
||||
- allowed tools
|
||||
- skip permissions mode
|
||||
- system prompt append
|
||||
- MCP config passthrough
|
||||
- session TTL
|
||||
- concurrency limits
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy
|
||||
cd openclaw-claude-proxy
|
||||
npm install
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
Default base URL:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3456/v1
|
||||
```
|
||||
|
||||
## Quick OpenAI-compatible config
|
||||
|
||||
```json
|
||||
{
|
||||
"baseURL": "http://127.0.0.1:3456/v1",
|
||||
"apiKey": "anything"
|
||||
}
|
||||
```
|
||||
|
||||
If `PROXY_API_KEY` is unset, auth is disabled. If you set it, pass it as a Bearer token.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---:|---|
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||
| `CLAUDE_BIN` | auto-detect | Claude CLI binary path |
|
||||
| `CLAUDE_TIMEOUT` | `120000` | Overall per-request timeout |
|
||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `30000` | Abort if Claude produces no stdout quickly |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | expanded set | Comma-separated allowed tools |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass permission checks |
|
||||
| `CLAUDE_SYSTEM_PROMPT` | unset | Append a system prompt to every request |
|
||||
| `CLAUDE_MCP_CONFIG` | unset | Path to MCP config JSON |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session TTL |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent Claude processes |
|
||||
| `PROXY_API_KEY` | unset | Optional Bearer token auth |
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions`
|
||||
- `GET /sessions`
|
||||
- `DELETE /sessions`
|
||||
|
||||
## Example health response highlights
|
||||
|
||||
`/health` reports useful operational state such as:
|
||||
|
||||
- resolved Claude binary path
|
||||
- whether the binary is executable
|
||||
- auth status
|
||||
- timeouts
|
||||
- current sessions
|
||||
- recent errors
|
||||
- basic request stats
|
||||
|
||||
## Version highlights
|
||||
|
||||
### v2.3.0
|
||||
- clarified v2 positioning and coexistence story in docs
|
||||
- officially documents faster fallback defaults
|
||||
- recommends OCP v2 as the API bridge layer for Claude-powered tools
|
||||
|
||||
### v2.2.0
|
||||
- first-byte timeout
|
||||
- reduced default timeout for faster fallback
|
||||
|
||||
### v2.0.0
|
||||
- on-demand architecture
|
||||
- session management
|
||||
- full tool access
|
||||
- MCP + system prompt passthrough
|
||||
- concurrency control
|
||||
- coexistence with Claude Code interactive mode
|
||||
|
||||
## When to use OCP v2 vs Claude channel
|
||||
|
||||
Choose **OCP v2** when:
|
||||
- your app only supports OpenAI-compatible endpoints
|
||||
- you want routing / failover outside Claude
|
||||
- you want explicit health checks and local diagnostics
|
||||
- you want Claude available to multiple local tools through one endpoint
|
||||
|
||||
Choose **Claude channel** when:
|
||||
- you are primarily living inside Claude Code itself
|
||||
- you want Claude's native interactive workflow directly
|
||||
|
||||
Use **both together** when you want the best of both worlds.
|
||||
|
||||
---
|
||||
|
||||
If you already pay for Claude Pro or Max, OCP v2 turns that subscription into a practical local API bridge for the rest of your tooling stack.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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",
|
||||
"version": "2.4.0",
|
||||
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openclaw-claude-proxy": "./server.mjs"
|
||||
|
||||
+392
-270
@@ -1,21 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
|
||||
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
|
||||
*
|
||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||
* 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
|
||||
* v2.4.0:
|
||||
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||
* - On-demand spawning (no pool), session management, full tool access
|
||||
*
|
||||
* Env vars:
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: "claude")
|
||||
* CLAUDE_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)
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
||||
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
|
||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
|
||||
* CLAUDE_BREAKER_THRESHOLD — consecutive timeouts before circuit opens (default: 3)
|
||||
* CLAUDE_BREAKER_COOLDOWN — ms to wait before retrying after circuit opens (default: 60000)
|
||||
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
@@ -64,26 +73,85 @@ function resolveClaude() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────────
|
||||
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 TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
||||
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "45000", 10);
|
||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
||||
).split(",").map(s => s.trim()).filter(Boolean);
|
||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
|
||||
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "3", 10);
|
||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "60000", 10);
|
||||
|
||||
const VERSION = _pkg.version;
|
||||
const START_TIME = Date.now();
|
||||
|
||||
// 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.
|
||||
// ── Structured logging helper ───────────────────────────────────────────
|
||||
function logEvent(level, event, data = {}) {
|
||||
const entry = { ts: new Date().toISOString(), level, event, ...data };
|
||||
if (level === "error" || level === "warn") {
|
||||
console.error(JSON.stringify(entry));
|
||||
} else {
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-model circuit breaker ───────────────────────────────────────────
|
||||
// Tracks consecutive timeouts per model. When threshold is reached, the
|
||||
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
|
||||
// window, requests for this model fail fast with a clear error instead of
|
||||
// waiting for yet another timeout that would block the gateway.
|
||||
const breakers = new Map(); // cliModel → { failures, state, openedAt }
|
||||
|
||||
function getBreakerState(cliModel) {
|
||||
if (!breakers.has(cliModel)) {
|
||||
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
|
||||
}
|
||||
const b = breakers.get(cliModel);
|
||||
|
||||
// Auto-recover: if cooldown has elapsed, transition to half-open
|
||||
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
|
||||
b.state = "half-open";
|
||||
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function breakerRecordSuccess(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
if (b.failures > 0 || b.state !== "closed") {
|
||||
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
|
||||
}
|
||||
b.failures = 0;
|
||||
b.state = "closed";
|
||||
b.openedAt = 0;
|
||||
}
|
||||
|
||||
function breakerRecordTimeout(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
b.failures++;
|
||||
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
|
||||
|
||||
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
|
||||
b.state = "open";
|
||||
b.openedAt = Date.now();
|
||||
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model mapping ───────────────────────────────────────────────────────
|
||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||
const MODEL_MAP = {
|
||||
// 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",
|
||||
@@ -98,270 +166,287 @@ const MODELS = [
|
||||
{ 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.
|
||||
// ── Session management ──────────────────────────────────────────────────
|
||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
||||
|
||||
const 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() {
|
||||
setInterval(() => {
|
||||
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
|
||||
}
|
||||
for (const [id, s] of sessions) {
|
||||
if (now - s.lastUsed > SESSION_TTL) {
|
||||
sessions.delete(id);
|
||||
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// ── Stats & diagnostics ─────────────────────────────────────────────────
|
||||
const stats = {
|
||||
totalRequests: 0,
|
||||
activeRequests: 0,
|
||||
errors: 0,
|
||||
timeouts: 0,
|
||||
sessionHits: 0,
|
||||
sessionMisses: 0,
|
||||
oneOffRequests: 0,
|
||||
};
|
||||
const recentErrors = []; // last 20 errors
|
||||
|
||||
function trackError(msg) {
|
||||
stats.errors++;
|
||||
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
|
||||
if (recentErrors.length > 20) recentErrors.shift();
|
||||
}
|
||||
|
||||
// ── Auth health check ───────────────────────────────────────────────────
|
||||
let authStatus = { ok: null, lastCheck: 0, message: "" };
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
|
||||
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
|
||||
} catch (e) {
|
||||
const msg = (e.stderr || e.message || "").slice(0, 200);
|
||||
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
|
||||
console.error(`[auth] check failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(recycleStaleProcesses, 15000); // check every 15s
|
||||
// Check auth on start and every 10 minutes
|
||||
checkAuth();
|
||||
setInterval(checkAuth, 600000);
|
||||
|
||||
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)
|
||||
// ── Build CLI arguments ─────────────────────────────────────────────────
|
||||
function buildCliArgs(cliModel, sessionInfo) {
|
||||
const args = ["-p", "--model", cliModel, "--output-format", "text"];
|
||||
|
||||
// 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;
|
||||
// Session handling
|
||||
if (sessionInfo?.resume) {
|
||||
args.push("--resume", sessionInfo.uuid);
|
||||
} else if (sessionInfo?.uuid) {
|
||||
args.push("--session-id", sessionInfo.uuid);
|
||||
} else {
|
||||
args.push("--no-session-persistence");
|
||||
}
|
||||
|
||||
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;
|
||||
// Permissions
|
||||
if (SKIP_PERMISSIONS) {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
} else if (ALLOWED_TOOLS.length > 0) {
|
||||
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// System prompt
|
||||
if (SYSTEM_PROMPT) {
|
||||
args.push("--append-system-prompt", SYSTEM_PROMPT);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
// MCP config
|
||||
if (MCP_CONFIG) {
|
||||
args.push("--mcp-config", MCP_CONFIG);
|
||||
}
|
||||
|
||||
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);
|
||||
return args;
|
||||
}
|
||||
|
||||
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;
|
||||
// ── Format messages to prompt text ──────────────────────────────────────
|
||||
function messagesToPrompt(messages) {
|
||||
return messages.map((m) => {
|
||||
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
||||
if (m.role === "system") return `[System] ${text}`;
|
||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||
return text;
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
// Initialize pool for all models
|
||||
function initPool() {
|
||||
for (const cliModel of new Set(Object.values(MODEL_MAP))) {
|
||||
replenishPool(cliModel);
|
||||
}
|
||||
// Model tier multipliers for first-byte timeout.
|
||||
// Opus is much slower to produce first token, especially with large contexts.
|
||||
const MODEL_TIMEOUT_TIERS = {
|
||||
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
|
||||
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
|
||||
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
|
||||
};
|
||||
|
||||
function getModelTier(cliModel) {
|
||||
if (cliModel.includes("opus")) return "opus";
|
||||
if (cliModel.includes("haiku")) return "haiku";
|
||||
return "sonnet";
|
||||
}
|
||||
|
||||
function computeFirstByteTimeout(cliModel, promptLength) {
|
||||
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
|
||||
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
|
||||
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||
}
|
||||
|
||||
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||
function callClaude(model, messages) {
|
||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||
// No pool = no crash loops, no stale workers, no degraded states.
|
||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||
function callClaude(model, messages, conversationId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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");
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||
}
|
||||
|
||||
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"] });
|
||||
// Circuit breaker check: fail fast if model is in open state
|
||||
const breaker = getBreakerState(cliModel);
|
||||
if (breaker.state === "open") {
|
||||
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
|
||||
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
|
||||
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
|
||||
}
|
||||
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
|
||||
let sessionInfo = null;
|
||||
let prompt;
|
||||
|
||||
// ── Session logic ──
|
||||
if (conversationId && sessions.has(conversationId)) {
|
||||
// Resume existing session: only send the latest user message
|
||||
const session = sessions.get(conversationId);
|
||||
session.lastUsed = Date.now();
|
||||
sessionInfo = { uuid: session.uuid, resume: true };
|
||||
stats.sessionHits++;
|
||||
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
prompt = lastUserMsg
|
||||
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
|
||||
: "";
|
||||
session.messageCount = messages.length;
|
||||
|
||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||
|
||||
} else if (conversationId) {
|
||||
// New session: send all messages, persist session for future --resume
|
||||
const uuid = randomUUID();
|
||||
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
|
||||
sessionInfo = { uuid, resume: false };
|
||||
stats.sessionMisses++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
|
||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||
|
||||
} else {
|
||||
// One-off request, no session
|
||||
stats.oneOffRequests++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
}
|
||||
|
||||
const cliArgs = buildCliArgs(cliModel, sessionInfo);
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const t0 = Date.now();
|
||||
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||
let settled = false;
|
||||
let gotFirstByte = false;
|
||||
|
||||
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}`));
|
||||
function settle(err, result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(firstByteTimer);
|
||||
stats.activeRequests--;
|
||||
|
||||
if (err) {
|
||||
trackError(err.message || String(err));
|
||||
|
||||
// If session resume failed, remove session so next request starts fresh
|
||||
if (sessionInfo?.resume && conversationId) {
|
||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||
sessions.delete(conversationId);
|
||||
}
|
||||
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms pool=${usedPool}`);
|
||||
resolve(stdout.trim());
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
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));
|
||||
proc.stdout.on("data", (d) => {
|
||||
if (!gotFirstByte) {
|
||||
gotFirstByte = true;
|
||||
clearTimeout(firstByteTimer);
|
||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||
}
|
||||
stdout += d;
|
||||
});
|
||||
proc.stderr.on("data", (d) => (stderr += d));
|
||||
|
||||
proc.on("close", (code, signal) => {
|
||||
const elapsed = Date.now() - t0;
|
||||
if (settled) {
|
||||
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||
} else {
|
||||
breakerRecordSuccess(cliModel);
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
settle(null, stdout.trim());
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[claude] spawn error: ${err.message}`);
|
||||
settle(err);
|
||||
});
|
||||
|
||||
// Write prompt to stdin immediately — no idle timeout issue
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
// First-byte timeout: abort early if Claude CLI produces no output
|
||||
const firstByteTimer = setTimeout(() => {
|
||||
if (!gotFirstByte && !settled) {
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
|
||||
}
|
||||
}, firstByteTimeoutMs);
|
||||
|
||||
// Overall request timeout with graceful kill
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||
}, TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Response helpers ────────────────────────────────────────────────────
|
||||
function jsonResponse(res, status, data) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
@@ -371,25 +456,23 @@ function sendSSE(res, data) {
|
||||
}
|
||||
|
||||
function streamResponse(res, id, model, content) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
// 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" }],
|
||||
@@ -420,10 +503,13 @@ async function handleChatCompletions(req, res) {
|
||||
const model = parsed.model || "claude-sonnet-4-6";
|
||||
const stream = parsed.stream;
|
||||
|
||||
// Session ID: from request body, header, or null (one-off)
|
||||
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
|
||||
|
||||
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
|
||||
|
||||
try {
|
||||
const content = await callClaude(model, messages);
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
|
||||
if (stream) {
|
||||
@@ -444,8 +530,8 @@ async function handleChatCompletions(req, res) {
|
||||
// ── 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");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
|
||||
@@ -473,49 +559,85 @@ const server = createServer(async (req, res) => {
|
||||
return handleChatCompletions(req, res);
|
||||
}
|
||||
|
||||
// GET /health — includes pool status, version, uptime
|
||||
// GET /health — comprehensive diagnostics
|
||||
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 {}
|
||||
|
||||
const uptimeMs = Date.now() - START_TIME;
|
||||
const sessionList = [];
|
||||
for (const [id, s] of sessions) {
|
||||
sessionList.push({
|
||||
id: id.slice(0, 12) + "...",
|
||||
model: s.model,
|
||||
messages: s.messageCount,
|
||||
idleMs: Date.now() - s.lastUsed,
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
status: binaryOk ? "ok" : "degraded",
|
||||
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
|
||||
version: VERSION,
|
||||
architecture: "on-demand (v2)",
|
||||
uptime: uptimeMs,
|
||||
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`,
|
||||
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
||||
claudeBinary: CLAUDE,
|
||||
claudeBinaryOk: binaryOk,
|
||||
pool: poolStatus,
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
sessionTTL: SESSION_TTL,
|
||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
|
||||
mcpConfig: MCP_CONFIG || "(none)",
|
||||
},
|
||||
stats,
|
||||
sessions: sessionList,
|
||||
recentErrors: recentErrors.slice(-5),
|
||||
});
|
||||
}
|
||||
|
||||
// Catch-all: try to handle any POST with messages
|
||||
// DELETE /sessions — clear all sessions
|
||||
if (req.url === "/sessions" && req.method === "DELETE") {
|
||||
const count = sessions.size;
|
||||
sessions.clear();
|
||||
return jsonResponse(res, 200, { cleared: count });
|
||||
}
|
||||
|
||||
// GET /sessions — list active sessions
|
||||
if (req.url === "/sessions" && req.method === "GET") {
|
||||
const list = [];
|
||||
for (const [id, s] of sessions) {
|
||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||
}
|
||||
return jsonResponse(res, 200, { sessions: list });
|
||||
}
|
||||
|
||||
// Catch-all POST
|
||||
if (req.method === "POST") {
|
||||
return handleChatCompletions(req, res);
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
|
||||
});
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────────────
|
||||
initPool();
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────────────
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
console.log(`Claude binary: ${CLAUDE}`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms`);
|
||||
console.log(`Pool size: ${POOL_SIZE} per model, max idle: ${POOL_MAX_IDLE / 1000}s`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
|
||||
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
|
||||
console.log(`---`);
|
||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
||||
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
|
||||
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
|
||||
console.log(` Both can run simultaneously on the same machine.`);
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "2.0.0",
|
||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
|
||||
"version": "2.5.0",
|
||||
"description": "OpenAI-compatible proxy for Claude CLI v2 — sliding-window circuit breaker, adaptive first-byte timeout, structured logging",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openclaw-claude-proxy": "./server.mjs"
|
||||
|
||||
+558
-160
@@ -1,33 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy v2.0.0 — OpenAI-compatible proxy for Claude CLI
|
||||
* openclaw-claude-proxy v2.5.0 — OpenAI-compatible proxy for Claude CLI
|
||||
*
|
||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||
*
|
||||
* v2.0.0 highlights:
|
||||
* - On-demand spawning: eliminates pool crash loops from v1.x
|
||||
* - Session management: --resume support reduces token waste on multi-turn
|
||||
* - Full tool access: configurable allowedTools (expanded defaults)
|
||||
* - System prompt & MCP config pass-through
|
||||
* - Concurrency control with queuing
|
||||
* - Coexists safely with Claude Code interactive mode (Telegram, IDE, etc.)
|
||||
* v2.5.0:
|
||||
* - Sliding-window circuit breaker: uses time-windowed failure rate instead of
|
||||
* consecutive-count, preventing multi-agent burst scenarios from tripping the
|
||||
* breaker too aggressively. Half-open state allows configurable probe requests.
|
||||
* - Graduated backoff: cooldown doubles on each re-open (capped at 5min),
|
||||
* resets fully on success.
|
||||
* - Health endpoint now exposes per-model breaker state and sliding window stats.
|
||||
* - Increased default timeout tiers for Opus/Sonnet to handle large agent prompts.
|
||||
*
|
||||
* v2.4.0:
|
||||
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||
* - On-demand spawning (no pool), session management, full tool access
|
||||
*
|
||||
* Env vars:
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 300000)
|
||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
|
||||
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 300000)
|
||||
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 90000)
|
||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8)
|
||||
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
||||
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
||||
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
||||
* CLAUDE_BREAKER_HALF_OPEN_MAX — max concurrent probes in half-open state (default: 2)
|
||||
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync, accessSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -76,6 +88,7 @@ function resolveClaude() {
|
||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||
const CLAUDE = resolveClaude();
|
||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
|
||||
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10);
|
||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
@@ -84,11 +97,149 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
|
||||
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
||||
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10);
|
||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
||||
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
||||
const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10);
|
||||
|
||||
const VERSION = _pkg.version;
|
||||
const START_TIME = Date.now();
|
||||
|
||||
// ── Structured logging helper ───────────────────────────────────────────
|
||||
function logEvent(level, event, data = {}) {
|
||||
const entry = { ts: new Date().toISOString(), level, event, ...data };
|
||||
if (level === "error" || level === "warn") {
|
||||
console.error(JSON.stringify(entry));
|
||||
} else {
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-model sliding-window circuit breaker ─────────────────────────────
|
||||
// Uses a time-windowed failure rate instead of consecutive-count. This prevents
|
||||
// multi-agent burst scenarios (e.g. ClawTeam spawning 5+ Opus agents) from
|
||||
// tripping the breaker after just 3 quick timeouts.
|
||||
//
|
||||
// States: closed → open → half-open → closed (on success) or open (on failure)
|
||||
// Half-open allows up to BREAKER_HALF_OPEN_MAX concurrent probes (not just 1).
|
||||
// Cooldown uses graduated backoff: doubles on each re-open, resets on success.
|
||||
const breakers = new Map(); // cliModel → BreakerState
|
||||
|
||||
function newBreakerState() {
|
||||
return {
|
||||
state: "closed", // closed | open | half-open
|
||||
failureTimestamps: [], // timestamps of failures within the sliding window
|
||||
successCount: 0, // successes within window (for rate calculation)
|
||||
openedAt: 0,
|
||||
currentCooldown: BREAKER_COOLDOWN, // graduates on repeated opens
|
||||
reopenCount: 0, // how many times breaker has re-opened without a full reset
|
||||
halfOpenProbes: 0, // active probe requests in half-open state
|
||||
};
|
||||
}
|
||||
|
||||
function pruneWindow(b) {
|
||||
const cutoff = Date.now() - BREAKER_WINDOW;
|
||||
b.failureTimestamps = b.failureTimestamps.filter(ts => ts > cutoff);
|
||||
}
|
||||
|
||||
function getBreakerState(cliModel) {
|
||||
if (!breakers.has(cliModel)) {
|
||||
breakers.set(cliModel, newBreakerState());
|
||||
}
|
||||
const b = breakers.get(cliModel);
|
||||
|
||||
// Auto-recover: if cooldown has elapsed, transition to half-open
|
||||
if (b.state === "open" && Date.now() - b.openedAt >= b.currentCooldown) {
|
||||
b.state = "half-open";
|
||||
b.halfOpenProbes = 0;
|
||||
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: b.currentCooldown, reopenCount: b.reopenCount });
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function breakerRecordSuccess(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
b.successCount++;
|
||||
|
||||
if (b.state === "half-open") {
|
||||
b.halfOpenProbes = Math.max(0, b.halfOpenProbes - 1);
|
||||
}
|
||||
|
||||
if (b.state !== "closed") {
|
||||
logEvent("info", "breaker_reset", {
|
||||
model: cliModel,
|
||||
previousFailures: b.failureTimestamps.length,
|
||||
previousState: b.state,
|
||||
reopenCount: b.reopenCount,
|
||||
});
|
||||
// Full reset on success — graduated backoff resets too
|
||||
b.state = "closed";
|
||||
b.openedAt = 0;
|
||||
b.currentCooldown = BREAKER_COOLDOWN;
|
||||
b.reopenCount = 0;
|
||||
b.halfOpenProbes = 0;
|
||||
b.failureTimestamps = [];
|
||||
b.successCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function breakerRecordTimeout(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
const now = Date.now();
|
||||
b.failureTimestamps.push(now);
|
||||
pruneWindow(b);
|
||||
|
||||
if (b.state === "half-open") {
|
||||
b.halfOpenProbes = Math.max(0, b.halfOpenProbes - 1);
|
||||
}
|
||||
|
||||
const windowFailures = b.failureTimestamps.length;
|
||||
logEvent("warn", "breaker_failure", {
|
||||
model: cliModel,
|
||||
windowFailures,
|
||||
threshold: BREAKER_THRESHOLD,
|
||||
windowMs: BREAKER_WINDOW,
|
||||
state: b.state,
|
||||
});
|
||||
|
||||
if (windowFailures >= BREAKER_THRESHOLD && b.state !== "open") {
|
||||
b.state = "open";
|
||||
b.openedAt = now;
|
||||
b.halfOpenProbes = 0;
|
||||
// Graduated backoff: double cooldown on each re-open, cap at 5 min
|
||||
if (b.reopenCount > 0) {
|
||||
b.currentCooldown = Math.min(b.currentCooldown * 2, 300000);
|
||||
}
|
||||
b.reopenCount++;
|
||||
logEvent("error", "breaker_open", {
|
||||
model: cliModel,
|
||||
windowFailures,
|
||||
cooldownMs: b.currentCooldown,
|
||||
reopenCount: b.reopenCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Expose breaker snapshot for /health endpoint
|
||||
function getBreakerSnapshot() {
|
||||
const snapshot = {};
|
||||
for (const [model, b] of breakers) {
|
||||
pruneWindow(b);
|
||||
snapshot[model] = {
|
||||
state: b.state,
|
||||
windowFailures: b.failureTimestamps.length,
|
||||
threshold: BREAKER_THRESHOLD,
|
||||
windowMs: BREAKER_WINDOW,
|
||||
currentCooldown: b.currentCooldown,
|
||||
reopenCount: b.reopenCount,
|
||||
halfOpenProbes: b.halfOpenProbes,
|
||||
...(b.openedAt ? { openedAt: new Date(b.openedAt).toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// ── Model mapping ───────────────────────────────────────────────────────
|
||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||
const MODEL_MAP = {
|
||||
@@ -114,7 +265,7 @@ const MODELS = [
|
||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
||||
|
||||
setInterval(() => {
|
||||
const sessionCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, s] of sessions) {
|
||||
if (now - s.lastUsed > SESSION_TTL) {
|
||||
@@ -124,6 +275,9 @@ setInterval(() => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// ── Active child process tracking ────────────────────────────────────────
|
||||
const activeProcesses = new Set();
|
||||
|
||||
// ── Stats & diagnostics ─────────────────────────────────────────────────
|
||||
const stats = {
|
||||
totalRequests: 0,
|
||||
@@ -163,7 +317,7 @@ async function checkAuth() {
|
||||
|
||||
// Check auth on start and every 10 minutes
|
||||
checkAuth();
|
||||
setInterval(checkAuth, 600000);
|
||||
const authCheckInterval = setInterval(checkAuth, 600000);
|
||||
|
||||
// ── Build CLI arguments ─────────────────────────────────────────────────
|
||||
function buildCliArgs(cliModel, sessionInfo) {
|
||||
@@ -208,123 +362,313 @@ function messagesToPrompt(messages) {
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||
// Model tier multipliers for first-byte timeout.
|
||||
// Opus is much slower to produce first token, especially with large contexts.
|
||||
const MODEL_TIMEOUT_TIERS = {
|
||||
"opus": { base: 90000, perPromptChar: 0.00020 }, // 90s base + ~20s per 100k chars
|
||||
"sonnet": { base: 60000, perPromptChar: 0.00010 }, // 60s base + ~10s per 100k chars
|
||||
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
|
||||
};
|
||||
|
||||
function getModelTier(cliModel) {
|
||||
if (cliModel.includes("opus")) return "opus";
|
||||
if (cliModel.includes("haiku")) return "haiku";
|
||||
return "sonnet";
|
||||
}
|
||||
|
||||
function computeFirstByteTimeout(cliModel, promptLength) {
|
||||
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
|
||||
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
|
||||
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||
}
|
||||
|
||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||
// timeouts. Returns context object or throws synchronously.
|
||||
function spawnClaudeProcess(model, messages, conversationId) {
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||
}
|
||||
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
|
||||
// Circuit breaker check: fail fast if model is in open state
|
||||
const breaker = getBreakerState(cliModel);
|
||||
if (breaker.state === "open") {
|
||||
const remainingMs = breaker.currentCooldown - (Date.now() - breaker.openedAt);
|
||||
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs, reopenCount: breaker.reopenCount });
|
||||
throw new Error(`circuit breaker open for ${cliModel}: ${breaker.failureTimestamps.length} timeouts in window, retry in ${Math.ceil(remainingMs / 1000)}s`);
|
||||
}
|
||||
// Half-open: allow limited probe requests
|
||||
if (breaker.state === "half-open" && breaker.halfOpenProbes >= BREAKER_HALF_OPEN_MAX) {
|
||||
logEvent("warn", "breaker_half_open_full", { model: cliModel, activeProbes: breaker.halfOpenProbes, max: BREAKER_HALF_OPEN_MAX });
|
||||
throw new Error(`circuit breaker half-open for ${cliModel}: ${breaker.halfOpenProbes}/${BREAKER_HALF_OPEN_MAX} probe slots in use, wait for probe result`);
|
||||
}
|
||||
if (breaker.state === "half-open") {
|
||||
breaker.halfOpenProbes++;
|
||||
logEvent("info", "breaker_probe", { model: cliModel, activeProbes: breaker.halfOpenProbes, max: BREAKER_HALF_OPEN_MAX });
|
||||
}
|
||||
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
|
||||
let sessionInfo = null;
|
||||
let prompt;
|
||||
|
||||
// ── Session logic ──
|
||||
if (conversationId && sessions.has(conversationId)) {
|
||||
const session = sessions.get(conversationId);
|
||||
session.lastUsed = Date.now();
|
||||
sessionInfo = { uuid: session.uuid, resume: true };
|
||||
stats.sessionHits++;
|
||||
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
prompt = lastUserMsg
|
||||
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
|
||||
: "";
|
||||
session.messageCount = messages.length;
|
||||
|
||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||
|
||||
} else if (conversationId) {
|
||||
const uuid = randomUUID();
|
||||
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
|
||||
sessionInfo = { uuid, resume: false };
|
||||
stats.sessionMisses++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
|
||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||
|
||||
} else {
|
||||
stats.oneOffRequests++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
}
|
||||
|
||||
const cliArgs = buildCliArgs(cliModel, sessionInfo);
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
activeProcesses.add(proc);
|
||||
|
||||
const t0 = Date.now();
|
||||
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||
let gotFirstByte = false;
|
||||
let cleaned = false;
|
||||
|
||||
function cleanup() {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
clearTimeout(overallTimer);
|
||||
clearTimeout(firstByteTimer);
|
||||
stats.activeRequests--;
|
||||
}
|
||||
|
||||
function handleSessionFailure() {
|
||||
if (sessionInfo?.resume && conversationId) {
|
||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||
sessions.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
function markFirstByte() {
|
||||
if (!gotFirstByte) {
|
||||
gotFirstByte = true;
|
||||
clearTimeout(firstByteTimer);
|
||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write prompt to stdin immediately
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
// First-byte timeout
|
||||
const firstByteTimer = setTimeout(() => {
|
||||
if (!gotFirstByte && !cleaned) {
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
}
|
||||
}, firstByteTimeoutMs);
|
||||
|
||||
// Overall request timeout
|
||||
const overallTimer = setTimeout(() => {
|
||||
if (!cleaned) {
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
return { proc, cliModel, conversationId, t0, cleanup, handleSessionFailure, markFirstByte };
|
||||
}
|
||||
|
||||
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
|
||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||
// No pool = no crash loops, no stale workers, no degraded states.
|
||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||
function callClaude(model, messages, conversationId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||
}
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
let sessionInfo = null;
|
||||
let prompt;
|
||||
|
||||
// ── Session logic ──
|
||||
if (conversationId && sessions.has(conversationId)) {
|
||||
// Resume existing session: only send the latest user message
|
||||
const session = sessions.get(conversationId);
|
||||
session.lastUsed = Date.now();
|
||||
sessionInfo = { uuid: session.uuid, resume: true };
|
||||
stats.sessionHits++;
|
||||
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
prompt = lastUserMsg
|
||||
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
|
||||
: "";
|
||||
session.messageCount = messages.length;
|
||||
|
||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||
|
||||
} else if (conversationId) {
|
||||
// New session: send all messages, persist session for future --resume
|
||||
const uuid = randomUUID();
|
||||
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
|
||||
sessionInfo = { uuid, resume: false };
|
||||
stats.sessionMisses++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
|
||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||
|
||||
} else {
|
||||
// One-off request, no session
|
||||
stats.oneOffRequests++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const cliArgs = buildCliArgs(cliModel, sessionInfo);
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const t0 = Date.now();
|
||||
let settled = false;
|
||||
|
||||
function settle(err, result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
stats.activeRequests--;
|
||||
|
||||
if (err) {
|
||||
trackError(err.message || String(err));
|
||||
|
||||
// If session resume failed, remove session so next request starts fresh
|
||||
if (sessionInfo?.resume && conversationId) {
|
||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||
sessions.delete(conversationId);
|
||||
}
|
||||
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
proc.stdout.on("data", (d) => (stdout += d));
|
||||
proc.stdout.on("data", (d) => {
|
||||
markFirstByte();
|
||||
stdout += d;
|
||||
});
|
||||
proc.stderr.on("data", (d) => (stderr += d));
|
||||
|
||||
proc.on("close", (code) => {
|
||||
proc.on("close", (code, signal) => {
|
||||
activeProcesses.delete(proc);
|
||||
const elapsed = Date.now() - t0;
|
||||
cleanup();
|
||||
if (code !== 0) {
|
||||
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 500)}`);
|
||||
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||
trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`);
|
||||
handleSessionFailure();
|
||||
reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||
} else {
|
||||
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
||||
settle(null, stdout.trim());
|
||||
breakerRecordSuccess(cliModel);
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[claude] spawn error: ${err.message}`);
|
||||
settle(err);
|
||||
cleanup();
|
||||
trackError(err.message);
|
||||
handleSessionFailure();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Write prompt to stdin immediately — no idle timeout issue
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
|
||||
// Each data chunk becomes a proper SSE event with delta content in real time.
|
||||
function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
console.log(`[claude] spawned model=${cliModel} prompt_chars=${prompt.length} session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||
} catch (err) {
|
||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
}
|
||||
|
||||
// Timeout with graceful kill
|
||||
const timer = setTimeout(() => {
|
||||
stats.timeouts++;
|
||||
console.error(`[claude] timeout after ${TIMEOUT}ms model=${cliModel}`);
|
||||
proc.kill("SIGTERM");
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||
}, TIMEOUT);
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||
let stderr = "";
|
||||
let headersSent = false;
|
||||
let totalChars = 0;
|
||||
|
||||
function ensureHeaders() {
|
||||
if (headersSent || res.writableEnded || res.destroyed) return false;
|
||||
headersSent = true;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
// Send initial role chunk
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
proc.stdout.on("data", (d) => {
|
||||
markFirstByte();
|
||||
const text = d.toString();
|
||||
totalChars += text.length;
|
||||
|
||||
if (!ensureHeaders()) return;
|
||||
|
||||
// Stream each chunk as it arrives from the CLI process
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
});
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (d) => (stderr += d));
|
||||
|
||||
proc.on("close", (code, signal) => {
|
||||
activeProcesses.delete(proc);
|
||||
cleanup();
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
if (code !== 0) {
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||
handleSessionFailure();
|
||||
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
// No output was sent yet — return a JSON error
|
||||
jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
// Already streaming — close the stream gracefully
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
} else {
|
||||
breakerRecordSuccess(cliModel);
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
if (!headersSent) ensureHeaders();
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[claude] spawn error: ${err.message}`);
|
||||
cleanup();
|
||||
trackError(err.message);
|
||||
handleSessionFailure();
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// If client disconnects, kill the process to free resources
|
||||
res.on("close", () => {
|
||||
if (!proc.killed) {
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -339,32 +683,6 @@ function sendSSE(res, data) {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function streamResponse(res, id, model, content) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
for (let i = 0; i < content.length; i += 500) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
|
||||
function completionResponse(res, id, model, content) {
|
||||
jsonResponse(res, 200, {
|
||||
id, object: "chat.completion",
|
||||
@@ -376,9 +694,19 @@ function completionResponse(res, id, model, content) {
|
||||
}
|
||||
|
||||
// ── Handle chat completions ─────────────────────────────────────────────
|
||||
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
// Set of all valid model identifiers (canonical IDs + aliases)
|
||||
const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
|
||||
|
||||
async function handleChatCompletions(req, res) {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > MAX_BODY_SIZE) {
|
||||
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
@@ -387,33 +715,43 @@ async function handleChatCompletions(req, res) {
|
||||
const model = parsed.model || "claude-sonnet-4-6";
|
||||
const stream = parsed.stream;
|
||||
|
||||
// Validate model against known models
|
||||
if (!VALID_MODELS.has(model)) {
|
||||
return jsonResponse(res, 400, { error: { message: `Unknown model: ${model}. Valid models: ${[...VALID_MODELS].join(", ")}`, type: "invalid_request_error" } });
|
||||
}
|
||||
|
||||
// Session ID: from request body, header, or null (one-off)
|
||||
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
|
||||
|
||||
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
|
||||
|
||||
if (stream) {
|
||||
// Real streaming: pipe stdout from claude process directly as SSE chunks
|
||||
return callClaudeStreaming(model, messages, conversationId, res);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
|
||||
if (stream) {
|
||||
streamResponse(res, id, model, content);
|
||||
} else {
|
||||
completionResponse(res, id, model, content);
|
||||
}
|
||||
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" } });
|
||||
// Sanitize error: strip internal file paths before sending to client
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
// Dynamic CORS: only allow localhost origins
|
||||
const origin = req.headers["origin"] || "";
|
||||
const isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
|
||||
res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : `http://127.0.0.1:${PORT}`);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
@@ -422,7 +760,9 @@ const server = createServer(async (req, res) => {
|
||||
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) {
|
||||
const tokenBuf = Buffer.from(token);
|
||||
const keyBuf = Buffer.from(PROXY_API_KEY);
|
||||
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
|
||||
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
|
||||
}
|
||||
}
|
||||
@@ -459,8 +799,11 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const breakerState = getBreakerSnapshot();
|
||||
const anyBreakerOpen = Object.values(breakerState).some(b => b.state === "open");
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
|
||||
status: binaryOk && authStatus.ok !== false && !anyBreakerOpen ? "ok" : "degraded",
|
||||
version: VERSION,
|
||||
architecture: "on-demand (v2)",
|
||||
uptime: uptimeMs,
|
||||
@@ -470,13 +813,19 @@ const server = createServer(async (req, res) => {
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
sessionTTL: SESSION_TTL,
|
||||
breakerThreshold: BREAKER_THRESHOLD,
|
||||
breakerCooldown: BREAKER_COOLDOWN,
|
||||
breakerWindow: BREAKER_WINDOW,
|
||||
breakerHalfOpenMax: BREAKER_HALF_OPEN_MAX,
|
||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
|
||||
mcpConfig: MCP_CONFIG || "(none)",
|
||||
},
|
||||
stats,
|
||||
breakers: breakerState,
|
||||
sessions: sessionList,
|
||||
recentErrors: recentErrors.slice(-5),
|
||||
});
|
||||
@@ -498,21 +847,70 @@ const server = createServer(async (req, res) => {
|
||||
return jsonResponse(res, 200, { sessions: list });
|
||||
}
|
||||
|
||||
// Catch-all POST
|
||||
if (req.method === "POST") {
|
||||
return handleChatCompletions(req, res);
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
|
||||
});
|
||||
|
||||
|
||||
// ── Graceful shutdown ────────────────────────────────────────────────────
|
||||
let shuttingDown = false;
|
||||
|
||||
function gracefulShutdown(signal) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logEvent("info", "shutdown_start", { signal });
|
||||
|
||||
// 1. Stop accepting new connections
|
||||
server.close(() => {
|
||||
logEvent("info", "shutdown_server_closed", {});
|
||||
});
|
||||
|
||||
// 2. Clear intervals/timers
|
||||
clearInterval(sessionCleanupInterval);
|
||||
clearInterval(authCheckInterval);
|
||||
|
||||
// 3. Kill all active child processes
|
||||
for (const proc of activeProcesses) {
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
}
|
||||
|
||||
// Force-kill any remaining processes after 5s, then exit
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
for (const proc of activeProcesses) {
|
||||
try { proc.kill("SIGKILL"); } catch {}
|
||||
}
|
||||
logEvent("warn", "shutdown_forced", { remainingProcesses: activeProcesses.size });
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
forceExitTimer.unref();
|
||||
|
||||
// If no active processes, exit immediately
|
||||
if (activeProcesses.size === 0) {
|
||||
logEvent("info", "shutdown_complete", {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Wait for active processes to finish
|
||||
const checkDone = setInterval(() => {
|
||||
if (activeProcesses.size === 0) {
|
||||
clearInterval(checkDone);
|
||||
logEvent("info", "shutdown_complete", {});
|
||||
process.exit(0);
|
||||
}
|
||||
}, 200);
|
||||
checkDone.unref();
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────────────
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`);
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
console.log(`Claude binary: ${CLAUDE}`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Circuit breaker: threshold=${BREAKER_THRESHOLD} in ${BREAKER_WINDOW/1000}s window, cooldown=${BREAKER_COOLDOWN/1000}s (graduated), half-open probes=${BREAKER_HALF_OPEN_MAX}`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||
|
||||
Reference in New Issue
Block a user