mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d95dbeebc | ||
|
|
9e6bef729b | ||
|
|
256b2bfb77 |
@@ -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",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
|
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openclaw-claude-proxy": "./server.mjs"
|
"openclaw-claude-proxy": "./server.mjs"
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* openclaw-claude-proxy v2.2.0 — OpenAI-compatible proxy for Claude CLI
|
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
|
||||||
*
|
*
|
||||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||||
*
|
*
|
||||||
* v2.0.0 highlights:
|
* v2.4.0:
|
||||||
* - On-demand spawning: eliminates pool crash loops from v1.x
|
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||||
* - Session management: --resume support reduces token waste on multi-turn
|
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||||
* - Full tool access: configurable allowedTools (expanded defaults)
|
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||||
* - System prompt & MCP config pass-through
|
* - On-demand spawning (no pool), session management, full tool access
|
||||||
* - Concurrency control with queuing
|
|
||||||
* - Coexists safely with Claude Code interactive mode (Telegram, IDE, etc.)
|
|
||||||
*
|
*
|
||||||
* 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: auto-detect)
|
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
||||||
* CLAUDE_FIRST_BYTE_TIMEOUT — abort if no stdout within this ms (default: 30000)
|
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
|
||||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
|
* 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)
|
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||||
*/
|
*/
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
@@ -77,7 +77,7 @@ function resolveClaude() {
|
|||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||||
const CLAUDE = resolveClaude();
|
const CLAUDE = resolveClaude();
|
||||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
||||||
const FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "30000", 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 PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||||
@@ -87,10 +87,65 @@ const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
|||||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
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 || "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 VERSION = _pkg.version;
|
||||||
const START_TIME = Date.now();
|
const START_TIME = Date.now();
|
||||||
|
|
||||||
|
// ── Structured logging helper ───────────────────────────────────────────
|
||||||
|
function logEvent(level, event, data = {}) {
|
||||||
|
const entry = { ts: new Date().toISOString(), level, event, ...data };
|
||||||
|
if (level === "error" || level === "warn") {
|
||||||
|
console.error(JSON.stringify(entry));
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-model circuit breaker ───────────────────────────────────────────
|
||||||
|
// Tracks consecutive timeouts per model. When threshold is reached, the
|
||||||
|
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
|
||||||
|
// window, requests for this model fail fast with a clear error instead of
|
||||||
|
// waiting for yet another timeout that would block the gateway.
|
||||||
|
const breakers = new Map(); // cliModel → { failures, state, openedAt }
|
||||||
|
|
||||||
|
function getBreakerState(cliModel) {
|
||||||
|
if (!breakers.has(cliModel)) {
|
||||||
|
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
|
||||||
|
}
|
||||||
|
const b = breakers.get(cliModel);
|
||||||
|
|
||||||
|
// Auto-recover: if cooldown has elapsed, transition to half-open
|
||||||
|
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
|
||||||
|
b.state = "half-open";
|
||||||
|
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakerRecordSuccess(cliModel) {
|
||||||
|
const b = getBreakerState(cliModel);
|
||||||
|
if (b.failures > 0 || b.state !== "closed") {
|
||||||
|
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
|
||||||
|
}
|
||||||
|
b.failures = 0;
|
||||||
|
b.state = "closed";
|
||||||
|
b.openedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakerRecordTimeout(cliModel) {
|
||||||
|
const b = getBreakerState(cliModel);
|
||||||
|
b.failures++;
|
||||||
|
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
|
||||||
|
|
||||||
|
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
|
||||||
|
b.state = "open";
|
||||||
|
b.openedAt = Date.now();
|
||||||
|
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Model mapping ───────────────────────────────────────────────────────
|
// ── Model mapping ───────────────────────────────────────────────────────
|
||||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||||
const MODEL_MAP = {
|
const MODEL_MAP = {
|
||||||
@@ -210,6 +265,26 @@ function messagesToPrompt(messages) {
|
|||||||
}).join("\n\n");
|
}).join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model tier multipliers for first-byte timeout.
|
||||||
|
// Opus is much slower to produce first token, especially with large contexts.
|
||||||
|
const MODEL_TIMEOUT_TIERS = {
|
||||||
|
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
|
||||||
|
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
|
||||||
|
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
|
||||||
|
};
|
||||||
|
|
||||||
|
function getModelTier(cliModel) {
|
||||||
|
if (cliModel.includes("opus")) return "opus";
|
||||||
|
if (cliModel.includes("haiku")) return "haiku";
|
||||||
|
return "sonnet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFirstByteTimeout(cliModel, promptLength) {
|
||||||
|
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
|
||||||
|
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
|
||||||
|
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Call claude CLI ─────────────────────────────────────────────────────
|
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
@@ -219,10 +294,20 @@ function callClaude(model, messages, conversationId) {
|
|||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
|
||||||
|
// Circuit breaker check: fail fast if model is in open state
|
||||||
|
const breaker = getBreakerState(cliModel);
|
||||||
|
if (breaker.state === "open") {
|
||||||
|
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
|
||||||
|
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
|
||||||
|
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
|
||||||
|
}
|
||||||
|
|
||||||
stats.activeRequests++;
|
stats.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
|
|
||||||
const cliModel = MODEL_MAP[model] || model;
|
|
||||||
let sessionInfo = null;
|
let sessionInfo = null;
|
||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
@@ -271,6 +356,7 @@ function callClaude(model, messages, conversationId) {
|
|||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let gotFirstByte = false;
|
let gotFirstByte = false;
|
||||||
|
|
||||||
@@ -306,13 +392,18 @@ function callClaude(model, messages, conversationId) {
|
|||||||
});
|
});
|
||||||
proc.stderr.on("data", (d) => (stderr += d));
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
|
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code, signal) => {
|
||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
|
if (settled) {
|
||||||
|
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 500)}`);
|
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}`));
|
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||||
} else {
|
} else {
|
||||||
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
breakerRecordSuccess(cliModel);
|
||||||
|
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||||
settle(null, stdout.trim());
|
settle(null, stdout.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -326,24 +417,27 @@ function callClaude(model, messages, conversationId) {
|
|||||||
proc.stdin.write(prompt);
|
proc.stdin.write(prompt);
|
||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
|
|
||||||
console.log(`[claude] spawned model=${cliModel} prompt_chars=${prompt.length} session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
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
|
// First-byte timeout: abort early if Claude CLI produces no output
|
||||||
const firstByteTimer = setTimeout(() => {
|
const firstByteTimer = setTimeout(() => {
|
||||||
if (!gotFirstByte) {
|
if (!gotFirstByte && !settled) {
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
console.error(`[claude] first-byte timeout after ${FIRST_BYTE_TIMEOUT}ms model=${cliModel} — aborting`);
|
breakerRecordTimeout(cliModel);
|
||||||
proc.kill("SIGTERM");
|
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||||
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||||
settle(new Error(`first-byte timeout after ${FIRST_BYTE_TIMEOUT}ms`));
|
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
|
||||||
}
|
}
|
||||||
}, FIRST_BYTE_TIMEOUT);
|
}, firstByteTimeoutMs);
|
||||||
|
|
||||||
// Overall request timeout with graceful kill
|
// Overall request timeout with graceful kill
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
console.error(`[claude] timeout after ${TIMEOUT}ms model=${cliModel}`);
|
breakerRecordTimeout(cliModel);
|
||||||
proc.kill("SIGTERM");
|
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||||
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||||
}, TIMEOUT);
|
}, TIMEOUT);
|
||||||
@@ -492,7 +586,7 @@ const server = createServer(async (req, res) => {
|
|||||||
auth: authStatus,
|
auth: authStatus,
|
||||||
config: {
|
config: {
|
||||||
timeout: TIMEOUT,
|
timeout: TIMEOUT,
|
||||||
firstByteTimeout: FIRST_BYTE_TIMEOUT,
|
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||||
maxConcurrent: MAX_CONCURRENT,
|
maxConcurrent: MAX_CONCURRENT,
|
||||||
sessionTTL: SESSION_TTL,
|
sessionTTL: SESSION_TTL,
|
||||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||||
@@ -535,7 +629,7 @@ server.listen(PORT, "0.0.0.0", () => {
|
|||||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||||
console.log(`Claude binary: ${CLAUDE}`);
|
console.log(`Claude binary: ${CLAUDE}`);
|
||||||
console.log(`Timeout: ${TIMEOUT}ms (first-byte: ${FIRST_BYTE_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(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
|
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openclaw-claude-proxy": "./server.mjs"
|
"openclaw-claude-proxy": "./server.mjs"
|
||||||
|
|||||||
+118
-24
@@ -1,29 +1,29 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* openclaw-claude-proxy v2.2.0 — OpenAI-compatible proxy for Claude CLI
|
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
|
||||||
*
|
*
|
||||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||||
*
|
*
|
||||||
* v2.0.0 highlights:
|
* v2.4.0:
|
||||||
* - On-demand spawning: eliminates pool crash loops from v1.x
|
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||||
* - Session management: --resume support reduces token waste on multi-turn
|
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||||
* - Full tool access: configurable allowedTools (expanded defaults)
|
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||||
* - System prompt & MCP config pass-through
|
* - On-demand spawning (no pool), session management, full tool access
|
||||||
* - Concurrency control with queuing
|
|
||||||
* - Coexists safely with Claude Code interactive mode (Telegram, IDE, etc.)
|
|
||||||
*
|
*
|
||||||
* 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: auto-detect)
|
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
||||||
* CLAUDE_FIRST_BYTE_TIMEOUT — abort if no stdout within this ms (default: 30000)
|
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
|
||||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
|
* 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)
|
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||||
*/
|
*/
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
@@ -77,7 +77,7 @@ function resolveClaude() {
|
|||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||||
const CLAUDE = resolveClaude();
|
const CLAUDE = resolveClaude();
|
||||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
||||||
const FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "30000", 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 PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||||
@@ -87,10 +87,65 @@ const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
|||||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
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 || "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 VERSION = _pkg.version;
|
||||||
const START_TIME = Date.now();
|
const START_TIME = Date.now();
|
||||||
|
|
||||||
|
// ── Structured logging helper ───────────────────────────────────────────
|
||||||
|
function logEvent(level, event, data = {}) {
|
||||||
|
const entry = { ts: new Date().toISOString(), level, event, ...data };
|
||||||
|
if (level === "error" || level === "warn") {
|
||||||
|
console.error(JSON.stringify(entry));
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-model circuit breaker ───────────────────────────────────────────
|
||||||
|
// Tracks consecutive timeouts per model. When threshold is reached, the
|
||||||
|
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
|
||||||
|
// window, requests for this model fail fast with a clear error instead of
|
||||||
|
// waiting for yet another timeout that would block the gateway.
|
||||||
|
const breakers = new Map(); // cliModel → { failures, state, openedAt }
|
||||||
|
|
||||||
|
function getBreakerState(cliModel) {
|
||||||
|
if (!breakers.has(cliModel)) {
|
||||||
|
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
|
||||||
|
}
|
||||||
|
const b = breakers.get(cliModel);
|
||||||
|
|
||||||
|
// Auto-recover: if cooldown has elapsed, transition to half-open
|
||||||
|
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
|
||||||
|
b.state = "half-open";
|
||||||
|
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakerRecordSuccess(cliModel) {
|
||||||
|
const b = getBreakerState(cliModel);
|
||||||
|
if (b.failures > 0 || b.state !== "closed") {
|
||||||
|
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
|
||||||
|
}
|
||||||
|
b.failures = 0;
|
||||||
|
b.state = "closed";
|
||||||
|
b.openedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakerRecordTimeout(cliModel) {
|
||||||
|
const b = getBreakerState(cliModel);
|
||||||
|
b.failures++;
|
||||||
|
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
|
||||||
|
|
||||||
|
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
|
||||||
|
b.state = "open";
|
||||||
|
b.openedAt = Date.now();
|
||||||
|
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Model mapping ───────────────────────────────────────────────────────
|
// ── Model mapping ───────────────────────────────────────────────────────
|
||||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||||
const MODEL_MAP = {
|
const MODEL_MAP = {
|
||||||
@@ -210,6 +265,26 @@ function messagesToPrompt(messages) {
|
|||||||
}).join("\n\n");
|
}).join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model tier multipliers for first-byte timeout.
|
||||||
|
// Opus is much slower to produce first token, especially with large contexts.
|
||||||
|
const MODEL_TIMEOUT_TIERS = {
|
||||||
|
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
|
||||||
|
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
|
||||||
|
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
|
||||||
|
};
|
||||||
|
|
||||||
|
function getModelTier(cliModel) {
|
||||||
|
if (cliModel.includes("opus")) return "opus";
|
||||||
|
if (cliModel.includes("haiku")) return "haiku";
|
||||||
|
return "sonnet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFirstByteTimeout(cliModel, promptLength) {
|
||||||
|
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
|
||||||
|
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
|
||||||
|
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Call claude CLI ─────────────────────────────────────────────────────
|
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
@@ -219,10 +294,20 @@ function callClaude(model, messages, conversationId) {
|
|||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
|
||||||
|
// Circuit breaker check: fail fast if model is in open state
|
||||||
|
const breaker = getBreakerState(cliModel);
|
||||||
|
if (breaker.state === "open") {
|
||||||
|
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
|
||||||
|
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
|
||||||
|
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
|
||||||
|
}
|
||||||
|
|
||||||
stats.activeRequests++;
|
stats.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
|
|
||||||
const cliModel = MODEL_MAP[model] || model;
|
|
||||||
let sessionInfo = null;
|
let sessionInfo = null;
|
||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
@@ -271,6 +356,7 @@ function callClaude(model, messages, conversationId) {
|
|||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let gotFirstByte = false;
|
let gotFirstByte = false;
|
||||||
|
|
||||||
@@ -306,13 +392,18 @@ function callClaude(model, messages, conversationId) {
|
|||||||
});
|
});
|
||||||
proc.stderr.on("data", (d) => (stderr += d));
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
|
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code, signal) => {
|
||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
|
if (settled) {
|
||||||
|
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 500)}`);
|
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}`));
|
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||||
} else {
|
} else {
|
||||||
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
breakerRecordSuccess(cliModel);
|
||||||
|
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||||
settle(null, stdout.trim());
|
settle(null, stdout.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -326,24 +417,27 @@ function callClaude(model, messages, conversationId) {
|
|||||||
proc.stdin.write(prompt);
|
proc.stdin.write(prompt);
|
||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
|
|
||||||
console.log(`[claude] spawned model=${cliModel} prompt_chars=${prompt.length} session=${conversationId ? conversationId.slice(0, 12) + "..." : "none"}`);
|
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
|
// First-byte timeout: abort early if Claude CLI produces no output
|
||||||
const firstByteTimer = setTimeout(() => {
|
const firstByteTimer = setTimeout(() => {
|
||||||
if (!gotFirstByte) {
|
if (!gotFirstByte && !settled) {
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
console.error(`[claude] first-byte timeout after ${FIRST_BYTE_TIMEOUT}ms model=${cliModel} — aborting`);
|
breakerRecordTimeout(cliModel);
|
||||||
proc.kill("SIGTERM");
|
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||||
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||||
settle(new Error(`first-byte timeout after ${FIRST_BYTE_TIMEOUT}ms`));
|
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
|
||||||
}
|
}
|
||||||
}, FIRST_BYTE_TIMEOUT);
|
}, firstByteTimeoutMs);
|
||||||
|
|
||||||
// Overall request timeout with graceful kill
|
// Overall request timeout with graceful kill
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
console.error(`[claude] timeout after ${TIMEOUT}ms model=${cliModel}`);
|
breakerRecordTimeout(cliModel);
|
||||||
proc.kill("SIGTERM");
|
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||||
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||||
}, TIMEOUT);
|
}, TIMEOUT);
|
||||||
@@ -492,7 +586,7 @@ const server = createServer(async (req, res) => {
|
|||||||
auth: authStatus,
|
auth: authStatus,
|
||||||
config: {
|
config: {
|
||||||
timeout: TIMEOUT,
|
timeout: TIMEOUT,
|
||||||
firstByteTimeout: FIRST_BYTE_TIMEOUT,
|
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||||
maxConcurrent: MAX_CONCURRENT,
|
maxConcurrent: MAX_CONCURRENT,
|
||||||
sessionTTL: SESSION_TTL,
|
sessionTTL: SESSION_TTL,
|
||||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||||
@@ -535,7 +629,7 @@ server.listen(PORT, "0.0.0.0", () => {
|
|||||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||||
console.log(`Claude binary: ${CLAUDE}`);
|
console.log(`Claude binary: ${CLAUDE}`);
|
||||||
console.log(`Timeout: ${TIMEOUT}ms (first-byte: ${FIRST_BYTE_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(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||||
|
|||||||
Reference in New Issue
Block a user