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