commit 593d0dcd5fc6d6ffdf52f578ab82f049d2177e44 Author: dtzp555 Date: Mon Mar 16 21:47:12 2026 +1000 Initial release: OpenAI-compatible proxy for Claude CLI Routes OpenClaw requests through `claude -p` CLI, letting you use Claude Pro/Max subscriptions as a model provider without API keys. - SSE streaming + non-streaming responses - Auto-setup script for OpenClaw configuration - Supports Opus 4.6, Sonnet 4.6, Haiku 4 Co-Authored-By: Claude Opus 4.6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..74d7421 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# openclaw-claude-proxy + +Use your Claude Pro/Max subscription as an OpenClaw model provider — no API key needed. + +## How it works + +``` +OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via OAuth) +``` + +The proxy translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage under your subscription — no API billing, no separate key. + +## Prerequisites + +- **Node.js** ≥ 18 +- **Claude CLI** installed and authenticated (`claude login`) +- **OpenClaw** installed + +## Quick Install + +```bash +# Clone +git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git +cd openclaw-claude-proxy + +# Auto-configure OpenClaw + start proxy +node setup.mjs +``` + +That's it. The setup script will: +1. Verify Claude CLI is installed and authenticated +2. Add `claude-local` provider to `openclaw.json` +3. Add auth profiles to all agents +4. Start the proxy + +Then set your preferred Claude model as default: +```bash +openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6" +openclaw gateway restart +``` + +## Manual Install + +### 1. Start the proxy + +```bash +node server.mjs +# or in background: +bash start.sh +``` + +### 2. Configure OpenClaw + +Add to `~/.openclaw/openclaw.json` under `models.providers`: + +```json +"claude-local": { + "baseUrl": "http://127.0.0.1:3456/v1", + "api": "openai-completions", + "authHeader": false, + "models": [ + { + "id": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "reasoning": true, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 16384 + }, + { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "reasoning": true, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 16384 + }, + { + "id": "claude-haiku-4", + "name": "Claude Haiku 4", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 8192 + } + ] +} +``` + +### 3. Set as default model + +```bash +openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6" +openclaw gateway restart +``` + +## Available Models + +| Model ID | Claude CLI model | Notes | +|----------|-----------------|-------| +| `claude-opus-4-6` | opus | Most capable, slower | +| `claude-sonnet-4-6` | sonnet | Good balance of speed/quality | +| `claude-haiku-4` | haiku | Fastest, lightweight | + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLAUDE_PROXY_PORT` | `3456` | Listen port | +| `CLAUDE_BIN` | `claude` | Path to claude binary | +| `CLAUDE_TIMEOUT` | `120000` | Request timeout (ms) | + +## API Endpoints + +- `GET /v1/models` — List available models +- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming) +- `GET /health` — Health check + +## Auto-start on Login (macOS) + +Add to your `~/.zshrc`: +```bash +bash ~/.openclaw/projects/claude-proxy/start.sh 2>/dev/null +``` + +## Notes + +- Cost shows as $0 because billing goes through your Claude subscription +- The `🔑` field in `/status` may show the dummy auth key — this is normal +- Each request spawns a `claude -p` process; concurrent requests are supported +- The proxy must run on the same machine as the Claude CLI (uses local OAuth) + +## License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..3f0b918 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "openclaw-claude-proxy", + "version": "1.0.0", + "description": "OpenAI-compatible proxy that routes requests through Claude CLI — use your Claude Pro/Max subscription as an OpenClaw model provider", + "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/server.mjs b/server.mjs new file mode 100644 index 0000000..766389b --- /dev/null +++ b/server.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/** + * openclaw-claude-proxy — 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. + * + * Supports both streaming (SSE) and non-streaming responses. + * + * Env vars: + * CLAUDE_PROXY_PORT — listen port (default: 3456) + * CLAUDE_BIN — path to claude binary (default: "claude") + */ +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; + +const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10); +const CLAUDE = process.env.CLAUDE_BIN || "claude"; +const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10); + +// Model alias mapping: request model → claude CLI --model arg +const MODEL_MAP = { + "claude-opus-4-6": "opus", + "claude-opus-4": "opus", + "claude-sonnet-4-6": "sonnet", + "claude-sonnet-4": "sonnet", + "claude-haiku-4": "haiku", +}; + +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", name: "Claude Haiku 4" }, +]; + +// ── Call claude CLI ───────────────────────────────────────────────────── +function callClaude(model, messages) { + return new Promise((resolve, reject) => { + const prompt = messages + .map((m) => { + const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content); + if (m.role === "system") return `[System] ${text}`; + if (m.role === "assistant") return `[Assistant] ${text}`; + return text; + }) + .join("\n\n"); + + const cliModel = MODEL_MAP[model] || model; + const args = ["-p", "--model", cliModel, "--output-format", "text", "--no-session-persistence", "--", prompt]; + const env = { ...process.env }; + delete env.CLAUDECODE; + + let stdout = ""; + let stderr = ""; + const proc = spawn(CLAUDE, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + proc.on("close", (code) => { + if (code !== 0) { + console.error(`[claude] exit=${code} model=${cliModel} stderr=${stderr.slice(0, 300)}`); + reject(new Error(stderr || stdout || `exit ${code}`)); + } else { + console.log(`[claude] ok model=${cliModel} chars=${stdout.length}`); + resolve(stdout.trim()); + } + }); + proc.on("error", reject); + const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT); + proc.on("close", () => clearTimeout(timer)); + }); +} + +// ── Response helpers ──────────────────────────────────────────────────── +function jsonResponse(res, status, data) { + 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) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + const created = Math.floor(Date.now() / 1000); + // Role chunk + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + // Content chunks (~500 chars each) + for (let i = 0; i < content.length; i += 500) { + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }], + }); + } + // Finish + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + 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; + + if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" }); + + try { + const content = await callClaude(model, messages); + 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}`); + 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, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + + // 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); + } + + // Health check + if (req.url === "/health") return jsonResponse(res, 200, { status: "ok" }); + + // Catch-all: try to handle any POST with messages + if (req.method === "POST") { + return handleChatCompletions(req, res); + } + + jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" }); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`openclaw-claude-proxy listening on http://127.0.0.1:${PORT}`); + console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`); + console.log(`Claude binary: ${CLAUDE}`); + console.log(`Timeout: ${TIMEOUT}ms`); +}); diff --git a/setup.mjs b/setup.mjs new file mode 100755 index 0000000..713026c --- /dev/null +++ b/setup.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +/** + * openclaw-claude-proxy setup + * + * Automatically configures OpenClaw to use Claude CLI as a model provider. + * Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run] + * + * What it does: + * 1. Verifies claude CLI is installed and authenticated + * 2. Patches openclaw.json — adds claude-local provider + models + * 3. Patches auth-profiles.json — adds dummy auth entry + * 4. Creates start.sh for easy launch + * 5. Optionally starts the proxy + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HOME = homedir(); +const OPENCLAW_DIR = process.env.OPENCLAW_STATE_DIR || join(HOME, ".openclaw"); +const CONFIG_PATH = join(OPENCLAW_DIR, "openclaw.json"); + +// ── Parse args ────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +const flag = (name) => args.includes(`--${name}`); +const opt = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i >= 0 && args[i + 1] ? args[i + 1] : fallback; +}; + +const PORT = parseInt(opt("port", "3456"), 10); +const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku +const DRY_RUN = flag("dry-run"); +const SKIP_START = flag("no-start"); +const PROVIDER_NAME = opt("provider-name", "claude-local"); + +const MODEL_ID_MAP = { + opus: "claude-opus-4-6", + sonnet: "claude-sonnet-4-6", + haiku: "claude-haiku-4", +}; +const DEFAULT_MODEL_ID = MODEL_ID_MAP[DEFAULT_MODEL] || MODEL_ID_MAP.opus; + +// ── Models to register ────────────────────────────────────────────────── +const MODELS = [ + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6 (via CLI)", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 16384, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (via CLI)", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 16384, + }, + { + id: "claude-haiku-4", + name: "Claude Haiku 4 (via CLI)", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, +]; + +const MODEL_ALIASES = { + [`${PROVIDER_NAME}/claude-opus-4-6`]: { alias: "Claude Opus 4.6" }, + [`${PROVIDER_NAME}/claude-sonnet-4-6`]: { alias: "Claude Sonnet 4.6" }, + [`${PROVIDER_NAME}/claude-haiku-4`]: { alias: "Claude Haiku 4" }, +}; + +// ── Helpers ───────────────────────────────────────────────────────────── +function log(msg) { console.log(` ✓ ${msg}`); } +function warn(msg) { console.log(` ⚠ ${msg}`); } +function fail(msg) { console.error(` ✗ ${msg}`); process.exit(1); } + +function readJSON(path) { + return JSON.parse(readFileSync(path, "utf-8")); +} + +function writeJSON(path, data) { + if (DRY_RUN) { + console.log(` [dry-run] would write ${path}`); + return; + } + writeFileSync(path, JSON.stringify(data, null, 2) + "\n"); +} + +// ── Step 1: Verify prerequisites ──────────────────────────────────────── +console.log("\n🔍 Checking prerequisites...\n"); + +// Check node version +const nodeVer = parseInt(process.versions.node.split(".")[0], 10); +if (nodeVer < 18) fail(`Node.js >= 18 required (found ${process.versions.node})`); +log(`Node.js ${process.versions.node}`); + +// Check claude CLI +try { + const ver = execSync("claude --version 2>/dev/null", { encoding: "utf-8" }).trim(); + log(`Claude CLI: ${ver}`); +} catch { + fail("Claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code"); +} + +// Check claude auth (quick test) +try { + const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', { + encoding: "utf-8", + timeout: 30000, + env: { ...process.env, CLAUDECODE: undefined }, + }).trim(); + if (out.length > 0) { + log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`); + } +} catch (e) { + warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`); + warn("Make sure you're logged in: claude login"); +} + +// Check openclaw config +if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`); +log(`OpenClaw config: ${CONFIG_PATH}`); + +// ── Step 2: Patch openclaw.json ───────────────────────────────────────── +console.log("\n📝 Configuring OpenClaw...\n"); + +const config = readJSON(CONFIG_PATH); + +// Ensure models.providers exists +if (!config.models) config.models = {}; +if (!config.models.providers) config.models.providers = {}; + +// Add/update claude-local provider +config.models.providers[PROVIDER_NAME] = { + baseUrl: `http://127.0.0.1:${PORT}/v1`, + api: "openai-completions", + authHeader: false, + models: MODELS, +}; +log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`); + +// Ensure auth profile in config +if (!config.auth) config.auth = {}; +if (!config.auth.profiles) config.auth.profiles = {}; +config.auth.profiles[`${PROVIDER_NAME}:default`] = { + provider: PROVIDER_NAME, + mode: "api_key", +}; +log(`Auth profile "${PROVIDER_NAME}:default" registered`); + +// Add models to agents.defaults.models +if (!config.agents) config.agents = {}; +if (!config.agents.defaults) config.agents.defaults = {}; +if (!config.agents.defaults.models) config.agents.defaults.models = {}; +for (const [key, val] of Object.entries(MODEL_ALIASES)) { + config.agents.defaults.models[key] = val; +} +log(`Model aliases added to agents.defaults.models`); + +writeJSON(CONFIG_PATH, config); +log(`Config saved`); + +// ── Step 3: Patch auth-profiles.json ──────────────────────────────────── +console.log("\n🔑 Configuring auth profiles...\n"); + +// Find all agent auth-profiles.json files +const agentsDir = join(OPENCLAW_DIR, "agents"); +const agentDirs = existsSync(agentsDir) + ? readdirSync(agentsDir).filter((d) => { + const ap = join(agentsDir, d, "agent", "auth-profiles.json"); + return existsSync(ap); + }) + : []; + +import { readdirSync } from "node:fs"; + +for (const agentId of agentDirs) { + const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json"); + try { + const ap = readJSON(apPath); + if (!ap.profiles) ap.profiles = {}; + + // Add claude-local profile if missing + if (!ap.profiles[`${PROVIDER_NAME}:default`]) { + ap.profiles[`${PROVIDER_NAME}:default`] = { + type: "api_key", + provider: PROVIDER_NAME, + key: "local-proxy-no-auth", + }; + } + + // Add to lastGood if missing + if (!ap.lastGood) ap.lastGood = {}; + if (!ap.lastGood[PROVIDER_NAME]) { + ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`; + } + + writeJSON(apPath, ap); + log(`Agent "${agentId}" auth profile updated`); + } catch (e) { + warn(`Skipped agent "${agentId}": ${e.message}`); + } +} + +if (agentDirs.length === 0) { + warn("No agent auth-profiles.json found — you may need to restart the gateway first"); +} + +// ── Step 4: Create start.sh ───────────────────────────────────────────── +console.log("\n🚀 Creating launcher...\n"); + +const serverPath = join(__dirname, "server.mjs"); +const logDir = join(OPENCLAW_DIR, "logs"); +if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }); + +const startSh = `#!/bin/bash +# Start openclaw-claude-proxy if not already running +PORT=\${CLAUDE_PROXY_PORT:-${PORT}} +if ! lsof -i :\$PORT -sTCP:LISTEN &>/dev/null; then + unset CLAUDECODE + nohup node "${serverPath}" \\ + >> "${logDir}/claude-proxy.log" \\ + 2>> "${logDir}/claude-proxy.err.log" & + echo "claude-proxy started on port \$PORT (pid $!)" +else + echo "claude-proxy already running on port \$PORT" +fi +`; + +const startPath = join(__dirname, "start.sh"); +if (!DRY_RUN) { + writeFileSync(startPath, startSh); + execSync(`chmod +x "${startPath}"`); +} +log(`Launcher: ${startPath}`); + +// ── Step 5: Summary ───────────────────────────────────────────────────── +console.log(` +╔══════════════════════════════════════════════════════════════╗ +║ Setup complete! ║ +╠══════════════════════════════════════════════════════════════╣ +║ ║ +║ Provider: ${PROVIDER_NAME.padEnd(44)}║ +║ Port: ${String(PORT).padEnd(44)}║ +║ Models: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4║ +║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║ +║ ║ +║ Start proxy: ║ +║ bash ${startPath.replace(HOME, "~").padEnd(50)}║ +║ ║ +║ Or directly: ║ +║ node ${serverPath.replace(HOME, "~").padEnd(49)}║ +║ ║ +║ Set as default model in openclaw.json: ║ +║ agents.defaults.model.primary = ║ +║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║ +║ ║ +║ Then restart gateway: ║ +║ openclaw gateway restart ║ +║ ║ +╚══════════════════════════════════════════════════════════════╝ +`); + +// ── Step 6: Optionally start ──────────────────────────────────────────── +if (!SKIP_START && !DRY_RUN) { + try { + execSync(`bash "${startPath}"`, { stdio: "inherit" }); + } catch { /* ignore */ } +} diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..4c3f386 --- /dev/null +++ b/start.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Start claude-proxy if not already running +if ! lsof -i :3456 -sTCP:LISTEN &>/dev/null; then + unset CLAUDECODE + nohup /opt/homebrew/bin/node /Users/taodeng/.openclaw/projects/claude-proxy/server.mjs \ + >> ~/.openclaw/logs/claude-proxy.log \ + 2>> ~/.openclaw/logs/claude-proxy.err.log & + echo "claude-proxy started (pid $!)" +else + echo "claude-proxy already running" +fi