Initial commit: OpenCode Anthropic proxy server
This commit is contained in:
commit
e3437d40e4
46
README.md
Normal file
46
README.md
Normal file
@ -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+
|
||||||
177
claude-proxy.mjs
Normal file
177
claude-proxy.mjs
Normal file
@ -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}`);
|
||||||
|
});
|
||||||
13
claude-proxy.service
Normal file
13
claude-proxy.service
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user