From e3437d40e493787c1258b71bf6b4228117d94fde Mon Sep 17 00:00:00 2001 From: Kenny Hibberd Date: Sun, 3 May 2026 16:02:47 -0700 Subject: [PATCH] Initial commit: OpenCode Anthropic proxy server --- README.md | 46 +++++++++++ claude-proxy.mjs | 177 +++++++++++++++++++++++++++++++++++++++++++ claude-proxy.service | 13 ++++ 3 files changed, 236 insertions(+) create mode 100644 README.md create mode 100644 claude-proxy.mjs create mode 100644 claude-proxy.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..efbd45f --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# OpenCode Anthropic Proxy + +A local proxy server that wraps `claude -p` (Claude Code CLI) to expose an OpenAI-compatible API for use with OpenCode. + +## Overview + +This proxy allows OpenCode to use Anthropic models by bridging OpenCode's OpenAI-compatible API calls to the `claude -p` CLI, which is the officially supported method for third-party integrations per Anthropic's terms of use. + +## Files + +- `claude-proxy.mjs` — The proxy server (Node.js, no external deps) +- `claude-proxy.service` — systemd user service for automatic startup + +## Setup + +1. Copy `claude-proxy.mjs` to `~/.config/opencode/claude-proxy.mjs` +2. Copy `claude-proxy.service` to `~/.config/systemd/user/claude-proxy.service` +3. Enable and start the service: + ```bash + systemctl --user daemon-reload + systemctl --user enable --now claude-proxy + ``` + +## OpenCode Configuration + +In your `opencode.json`, add the `claude-code` provider: + +```json +"claude-code": { + "npm": "@ai-sdk/openai-compatible", + "name": "Claude Code (proxy)", + "options": { + "baseURL": "http://localhost:3000/v1" + }, + "models": { + "claude-opus-4-7": { "name": "Claude Opus 4.7" }, + "claude-sonnet-4-6": { "name": "Claude Sonnet 4.6" }, + "claude-haiku-4-5": { "name": "Claude Haiku 4.5" } + } +} +``` + +## Requirements + +- Claude Code CLI installed at `~/.local/bin/claude` +- Node.js 18+ diff --git a/claude-proxy.mjs b/claude-proxy.mjs new file mode 100644 index 0000000..928c831 --- /dev/null +++ b/claude-proxy.mjs @@ -0,0 +1,177 @@ +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}`); +}); diff --git a/claude-proxy.service b/claude-proxy.service new file mode 100644 index 0000000..0c44bf5 --- /dev/null +++ b/claude-proxy.service @@ -0,0 +1,13 @@ +[Unit] +Description=Claude Code Proxy for OpenCode +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/node /home/kenny/.config/opencode/claude-proxy.mjs +Restart=always +RestartSec=5 +Environment=CLAUDE_PROXY_PORT=3000 + +[Install] +WantedBy=default.target