mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
fix: security hardening + real streaming (#4)
* Add graceful shutdown handling for SIGTERM and SIGINT On receiving SIGTERM or SIGINT, the server now: 1. Stops accepting new connections (server.close) 2. Clears session cleanup and auth check intervals 3. Sends SIGTERM to all active child processes 4. Waits up to 5s for processes to exit, then SIGKILL + exit(1) 5. Exits cleanly with exit(0) once all processes are gone Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: security hardening — bind localhost, validate models, limit body size - Bind to 127.0.0.1 instead of 0.0.0.0 to prevent network exposure - Restrict CORS Access-Control-Allow-Origin to http://127.0.0.1 - Add 10MB request body size limit (413 on exceed) - Validate model parameter against known MODEL_MAP keys (400 on unknown) - Remove catch-all POST route; only /v1/chat/completions accepted - Use crypto.timingSafeEqual for API key comparison - Sanitize error messages to strip internal file paths Bumps version to 2.4.1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace fake streaming with real stdout-piped SSE streaming Previously, streaming requests waited for the entire claude CLI process to complete, then split the output into artificial 500-char chunks sent as SSE events. This defeated the purpose of streaming and added latency. Now, stdout from the claude child process is piped directly to SSE chunks as data arrives. Each `data` event from the process stdout becomes an immediate SSE delta event, giving clients real incremental output. The shared process setup logic is extracted into spawnClaudeProcess() to avoid duplication between streaming and non-streaming paths. Client disconnect detection kills the child process to free resources. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: tighten CORS to dynamic localhost-only and reduce body limit to 5MB - CORS now dynamically matches localhost/127.0.0.1 origins instead of static http://127.0.0.1, preventing cross-origin abuse while supporting any localhost port - Body size limit reduced from 10MB to 5MB to limit OOM risk Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Oracle Public Cloud User
Claude Opus 4.6
parent
1d95dbeebc
commit
47434a8ba7
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+358
-189
@@ -28,7 +28,7 @@
|
||||
*/
|
||||
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";
|
||||
@@ -171,7 +171,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) {
|
||||
@@ -181,6 +181,9 @@ setInterval(() => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// ── Active child process tracking ────────────────────────────────────────
|
||||
const activeProcesses = new Set();
|
||||
|
||||
// ── Stats & diagnostics ─────────────────────────────────────────────────
|
||||
const stats = {
|
||||
totalRequests: 0,
|
||||
@@ -220,7 +223,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) {
|
||||
@@ -285,162 +288,284 @@ function computeFirstByteTimeout(cliModel, promptLength) {
|
||||
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||
}
|
||||
|
||||
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||
// 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})`));
|
||||
// ── 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_COOLDOWN - (Date.now() - breaker.openedAt);
|
||||
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
|
||||
throw 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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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.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;
|
||||
|
||||
function settle(err, result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
function markFirstByte() {
|
||||
if (!gotFirstByte) {
|
||||
gotFirstByte = true;
|
||||
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 {
|
||||
resolve(result);
|
||||
}
|
||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
// Write prompt to stdin immediately
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[claude] spawn error: ${err.message}`);
|
||||
settle(err);
|
||||
});
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
// 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);
|
||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||
}, TIMEOUT);
|
||||
}
|
||||
}, 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) => {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (d) => {
|
||||
markFirstByte();
|
||||
stdout += d;
|
||||
});
|
||||
proc.stderr.on("data", (d) => (stderr += d));
|
||||
|
||||
proc.on("close", (code, signal) => {
|
||||
activeProcesses.delete(proc);
|
||||
const elapsed = Date.now() - t0;
|
||||
cleanup();
|
||||
if (code !== 0) {
|
||||
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 {
|
||||
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}`);
|
||||
cleanup();
|
||||
trackError(err.message);
|
||||
handleSessionFailure();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||
} catch (err) {
|
||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,32 +580,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",
|
||||
@@ -492,9 +591,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" }); }
|
||||
@@ -503,33 +612,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; }
|
||||
@@ -538,7 +657,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" } });
|
||||
}
|
||||
}
|
||||
@@ -615,17 +736,65 @@ 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}`);
|
||||
|
||||
Reference in New Issue
Block a user