From 8b3f50912ed7d05ef285df93f57f61a4d8729353 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Tue, 5 May 2026 09:43:05 +1000 Subject: [PATCH] chore(repo): remove v2.x stale openclaw-claude-proxy/ folder + dead start.sh (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both removed: - `openclaw-claude-proxy/` — v2.4.0 stale duplicate of the proxy. Current proxy is `server.mjs` at repo root (v3.x); the nested folder was an early packaging artifact that never got deleted. Audit finding H1. - `start.sh` — hardcoded `/Users/taodeng/.openclaw/...` paths that only ever worked on the original maintainer's home directory. The real install is produced by `setup.mjs` (see setup.mjs:212-237 which writes the correct per-user start hook). Audit findings H5, H6 (path leak + dead script). Verification: `git grep "/Users/taodeng/"` returns 0 hits in tracked files. Refs: audit findings H1, H5, H6. Co-authored-by: dtzp555 --- openclaw-claude-proxy/README.md | 182 -------- openclaw-claude-proxy/package.json | 28 -- openclaw-claude-proxy/server.mjs | 643 ----------------------------- start.sh | 12 - 4 files changed, 865 deletions(-) delete mode 100644 openclaw-claude-proxy/README.md delete mode 100644 openclaw-claude-proxy/package.json delete mode 100644 openclaw-claude-proxy/server.mjs delete mode 100755 start.sh diff --git a/openclaw-claude-proxy/README.md b/openclaw-claude-proxy/README.md deleted file mode 100644 index e1906d5..0000000 --- a/openclaw-claude-proxy/README.md +++ /dev/null @@ -1,182 +0,0 @@ -# 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. diff --git a/openclaw-claude-proxy/package.json b/openclaw-claude-proxy/package.json deleted file mode 100644 index 37c6ffd..0000000 --- a/openclaw-claude-proxy/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "openclaw-claude-proxy", - "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" - }, - "scripts": { - "start": "node server.mjs", - "setup": "node setup.mjs" - }, - "keywords": [ - "openclaw", - "claude", - "proxy", - "openai", - "anthropic" - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "repository": { - "type": "git", - "url": "https://github.com/dtzp555-max/openclaw-claude-proxy" - } -} diff --git a/openclaw-claude-proxy/server.mjs b/openclaw-claude-proxy/server.mjs deleted file mode 100644 index 88339fe..0000000 --- a/openclaw-claude-proxy/server.mjs +++ /dev/null @@ -1,643 +0,0 @@ -#!/usr/bin/env node -/** - * 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. - * - * 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: 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"; -import { randomUUID } from "node:crypto"; -import { readFileSync, accessSync, constants } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); - -// ── Resolve claude binary ─────────────────────────────────────────────── -// Priority: CLAUDE_BIN env > well-known paths > which lookup -// Fail-fast if not found — never start with an unresolvable binary. -function resolveClaude() { - if (process.env.CLAUDE_BIN) { - try { - accessSync(process.env.CLAUDE_BIN, constants.X_OK); - return process.env.CLAUDE_BIN; - } catch { - console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`); - process.exit(1); - } - } - - const candidates = [ - "/opt/homebrew/bin/claude", - "/usr/local/bin/claude", - "/usr/bin/claude", - join(process.env.HOME || "", ".local/bin/claude"), - ]; - for (const p of candidates) { - try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {} - } - - try { - const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim(); - if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; } - } catch {} - - console.error( - "FATAL: claude binary not found.\n" + - " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" + - " Checked: " + candidates.join(", ") - ); - process.exit(1); -} - -// ── Configuration ─────────────────────────────────────────────────────── -const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10); -const CLAUDE = resolveClaude(); -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(); - -// ── 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 = { - "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", - "claude-opus-4": "claude-opus-4-6", - "claude-haiku-4": "claude-haiku-4-5-20251001", - "claude-haiku-4-5": "claude-haiku-4-5-20251001", - "opus": "claude-opus-4-6", - "sonnet": "claude-sonnet-4-6", - "haiku": "claude-haiku-4-5-20251001", -}; - -const MODELS = [ - { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" }, -]; - -// ── 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 } - -setInterval(() => { - const now = Date.now(); - 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}`); - } -} - -// Check auth on start and every 10 minutes -checkAuth(); -setInterval(checkAuth, 600000); - -// ── Build CLI arguments ───────────────────────────────────────────────── -function buildCliArgs(cliModel, sessionInfo) { - const args = ["-p", "--model", cliModel, "--output-format", "text"]; - - // 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"); - } - - // Permissions - if (SKIP_PERMISSIONS) { - args.push("--dangerously-skip-permissions"); - } else if (ALLOWED_TOOLS.length > 0) { - args.push("--allowedTools", ...ALLOWED_TOOLS); - } - - // System prompt - if (SYSTEM_PROMPT) { - args.push("--append-system-prompt", SYSTEM_PROMPT); - } - - // MCP config - if (MCP_CONFIG) { - args.push("--mcp-config", MCP_CONFIG); - } - - return args; -} - -// ── 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"); -} - -// 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 ───────────────────────────────────────────────────── -// 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})`)); - } - - 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); - 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); - } - } - - 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)); -} - -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", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - }); -} - -// ── Handle chat completions ───────────────────────────────────────────── -async function handleChatCompletions(req, res) { - let body = ""; - for await (const chunk of req) body += chunk; - - let parsed; - try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } - - const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }]; - const model = parsed.model || "claude-sonnet-4-6"; - const stream = parsed.stream; - - // 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, conversationId); - const id = `chatcmpl-${randomUUID()}`; - - if (stream) { - streamResponse(res, id, model, content); - } else { - completionResponse(res, id, model, content); - } - } catch (err) { - console.error(`[proxy] error: ${err.message}`); - if (res.headersSent || res.writableEnded || res.destroyed) { - try { res.end(); } catch {} - return; - } - jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } }); - } -} - -// ── HTTP server ───────────────────────────────────────────────────────── -const server = createServer(async (req, res) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, 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) - if (PROXY_API_KEY && req.url !== "/health") { - const auth = req.headers["authorization"] || ""; - const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - if (token !== PROXY_API_KEY) { - return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); - } - } - - // GET /v1/models - if (req.url === "/v1/models" && req.method === "GET") { - return jsonResponse(res, 200, { - object: "list", - data: MODELS.map((m) => ({ - id: m.id, object: "model", owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), - })), - }); - } - - // POST /v1/chat/completions - if (req.url === "/v1/chat/completions" && req.method === "POST") { - return handleChatCompletions(req, res); - } - - // GET /health — comprehensive diagnostics - if (req.url === "/health") { - 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 && 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`, - claudeBinary: CLAUDE, - claudeBinaryOk: binaryOk, - 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), - }); - } - - // 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, GET|DELETE /sessions" }); -}); - -// ── 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 (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.`); -}); diff --git a/start.sh b/start.sh deleted file mode 100755 index 8b68cb1..0000000 --- a/start.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# Start openclaw-claude-proxy if not already running -PORT=${CLAUDE_PROXY_PORT:-3456} -if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then - unset CLAUDECODE - nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/server.mjs" \ - >> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \ - 2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" & - echo "claude-proxy started on port $PORT (pid $!)" -else - echo "claude-proxy already running on port $PORT" -fi