178 lines
5.1 KiB
JavaScript
178 lines
5.1 KiB
JavaScript
import http from "node:http";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const PORT = process.env.CLAUDE_PROXY_PORT || 3000;
|
|
|
|
const MODEL_MAP = {
|
|
"claude-opus-4-7": "opus",
|
|
"claude-sonnet-4-6": "sonnet",
|
|
"claude-haiku-4-5": "haiku",
|
|
};
|
|
|
|
const TOOL_MAP = {
|
|
bash: "Bash",
|
|
read: "Read",
|
|
write: "Write",
|
|
edit: "Edit",
|
|
glob: "Glob",
|
|
grep: "Grep",
|
|
webfetch: "WebFetch",
|
|
websearch: "WebSearch",
|
|
question: "Question",
|
|
task: "Task",
|
|
todowrite: "TodoWrite",
|
|
skill: "Skill",
|
|
};
|
|
|
|
function parseBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on("data", (c) => chunks.push(c));
|
|
req.on("end", () => {
|
|
try {
|
|
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function buildMessages(body) {
|
|
const messages = body.messages || [];
|
|
const parts = [];
|
|
for (const msg of messages) {
|
|
if (msg.role === "system") {
|
|
parts.push(`System: ${msg.content}`);
|
|
} else if (msg.role === "user") {
|
|
parts.push(`User: ${msg.content}`);
|
|
} else if (msg.role === "assistant") {
|
|
parts.push(`Assistant: ${msg.content}`);
|
|
}
|
|
}
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
function extractTools(body) {
|
|
const tools = body.tools || [];
|
|
const claudeTools = [];
|
|
for (const tool of tools) {
|
|
const name = tool.function?.name || tool.name;
|
|
if (name) {
|
|
const mapped = TOOL_MAP[name.toLowerCase()] || name;
|
|
claudeTools.push(mapped);
|
|
}
|
|
}
|
|
return claudeTools;
|
|
}
|
|
|
|
async function callClaude(prompt, model, allowedTools) {
|
|
const modelWithoutPrefix = model.includes("/") ? model.split("/").pop() : model;
|
|
const modelAlias = MODEL_MAP[modelWithoutPrefix] || modelWithoutPrefix;
|
|
const args = ["-p", prompt, "--model", modelAlias, "--no-session-persistence", "--output-format", "json", "--permission-mode", "bypassPermissions"];
|
|
if (allowedTools.length > 0) {
|
|
args.push("--allowedTools", ...allowedTools);
|
|
}
|
|
const { stdout } = await execFileAsync("/home/kenny/.local/bin/claude", args, {
|
|
timeout: 300000,
|
|
maxBuffer: 1024 * 1024 * 10,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function handleRequest(req, res) {
|
|
if (req.method === "GET" && req.url === "/v1/models") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({
|
|
object: "list",
|
|
data: Object.keys(MODEL_MAP).map((id) => ({
|
|
id,
|
|
object: "model",
|
|
created: Math.floor(Date.now() / 1000),
|
|
owned_by: "anthropic",
|
|
})),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (req.method !== "POST" || !req.url.startsWith("/v1/chat/completions")) {
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const body = await parseBody(req);
|
|
const prompt = buildMessages(body);
|
|
const model = body.model || "claude-sonnet-4-6";
|
|
const stream = body.stream || false;
|
|
const allowedTools = extractTools(body);
|
|
|
|
const result = await callClaude(prompt, model, allowedTools);
|
|
|
|
const response = {
|
|
id: `chatcmpl-${Date.now()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: {
|
|
role: "assistant",
|
|
content: result.result || result.content || JSON.stringify(result),
|
|
},
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: result.usage?.input_tokens || 0,
|
|
completion_tokens: result.usage?.output_tokens || 0,
|
|
total_tokens: (result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0),
|
|
},
|
|
};
|
|
|
|
if (stream) {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
});
|
|
const chunk = {
|
|
id: response.id,
|
|
object: "chat.completion.chunk",
|
|
created: response.created,
|
|
model: response.model,
|
|
choices: [{ index: 0, delta: { role: "assistant", content: response.choices[0].message.content }, finish_reason: null }],
|
|
};
|
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
const endChunk = {
|
|
id: response.id,
|
|
object: "chat.completion.chunk",
|
|
created: response.created,
|
|
model: response.model,
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
};
|
|
res.write(`data: ${JSON.stringify(endChunk)}\n\n`);
|
|
res.write("data: [DONE]\n\n");
|
|
res.end();
|
|
} else {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(response));
|
|
}
|
|
} catch (err) {
|
|
console.error(`[${new Date().toISOString()}] Proxy error:`, err);
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: err.message }));
|
|
}
|
|
}
|
|
|
|
const server = http.createServer(handleRequest);
|
|
server.listen(PORT, () => {
|
|
console.log(`Claude proxy listening on http://localhost:${PORT}`);
|
|
});
|