Files
ocp/server.mjs
T
taodengandClaude Opus 4.6 92c55df021 Pre-approve common tools (Bash, Read, Write, Edit, Glob, Grep)
claude -p in non-interactive mode cannot show approval prompts,
so tools like gh/git were blocked. Add --allowedTools to enable
tool execution without manual approval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 22:10:09 +10:00

197 lines
7.0 KiB
JavaScript

#!/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",
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
"--", 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`);
});