112 lines
4.2 KiB
JavaScript
112 lines
4.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// claude-sub-proxy.js — OpenAI-compatible proxy routing through Claude Code CLI (Pro subscription)
|
|
// Accepts OpenAI chat completions format, routes through claude CLI, returns OpenAI format
|
|
// Usage: CLAUDE_CODE_OAUTH_TOKEN=<token> node claude-sub-proxy.js
|
|
|
|
import { createServer } from 'http';
|
|
import { spawn } from 'child_process';
|
|
|
|
const PORT = 8782;
|
|
const CLAUDE_BIN = '/home/openclaw/.npm-global/bin/claude';
|
|
|
|
// Build a plain-text prompt from OpenAI messages array
|
|
function buildPrompt(messages, system) {
|
|
const parts = [];
|
|
if (system) parts.push(`[SYSTEM]\n${system}\n[/SYSTEM]`);
|
|
for (const msg of messages) {
|
|
const role = msg.role === 'assistant' ? 'Assistant' : 'Human';
|
|
const content = Array.isArray(msg.content)
|
|
? msg.content.map(b => typeof b === 'string' ? b : (b.text || '')).join('')
|
|
: String(msg.content || '');
|
|
parts.push(`${role}: ${content}`);
|
|
}
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
// Strip provider prefix e.g. "sub-claude/claude-sonnet-4-5" → "claude-sonnet-4-5"
|
|
function cleanModel(model) {
|
|
return (model || 'claude-sonnet-4-5').replace(/^[^/]+\//, '');
|
|
}
|
|
|
|
function runClaude(prompt, model) {
|
|
return new Promise((resolve, reject) => {
|
|
const env = { ...process.env };
|
|
delete env.ANTHROPIC_API_KEY; // force CLAUDE_CODE_OAUTH_TOKEN auth
|
|
const child = spawn(CLAUDE_BIN, [
|
|
'-p', prompt,
|
|
'--output-format', 'json',
|
|
'--model', model,
|
|
'--dangerously-skip-permissions',
|
|
'--no-session-persistence',
|
|
], { env, timeout: 120000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
let stdout = '', stderr = '';
|
|
child.stdout.on('data', d => stdout += d);
|
|
child.stderr.on('data', d => stderr += d);
|
|
child.on('close', code => {
|
|
if (code !== 0) return reject(new Error(`claude exit ${code}: ${stderr.slice(0, 300)}`));
|
|
if (!stdout.trim()) return reject(new Error(`empty output. stderr: ${stderr.slice(0, 200)}`));
|
|
try { resolve(JSON.parse(stdout)); }
|
|
catch { reject(new Error(`bad JSON: ${stdout.slice(0, 200)}`)); }
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
// Health check
|
|
if (req.method === 'GET' && req.url === '/health') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, proxy: 'claude-sub-proxy' }));
|
|
return;
|
|
}
|
|
|
|
if (req.method !== 'POST') { res.writeHead(405); res.end(); return; }
|
|
|
|
let body = '';
|
|
req.on('data', d => body += d);
|
|
req.on('end', async () => {
|
|
try {
|
|
const parsed = JSON.parse(body);
|
|
const model = cleanModel(parsed.model);
|
|
const messages = parsed.messages || [];
|
|
const system = typeof parsed.system === 'string' ? parsed.system : undefined;
|
|
const prompt = buildPrompt(messages, system);
|
|
console.log(`[${new Date().toISOString()}] → ${model} (${prompt.length} chars)`);
|
|
|
|
const result = await runClaude(prompt, model);
|
|
if (result.is_error) throw new Error(result.result || 'Claude error');
|
|
|
|
console.log(`[${new Date().toISOString()}] ✅ cost=$${result.total_cost_usd?.toFixed(5) || '?'}`);
|
|
|
|
// Return OpenAI chat completions format
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
id: `chatcmpl_proxy_${Date.now()}`,
|
|
object: 'chat.completion',
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: parsed.model || model,
|
|
choices: [{
|
|
index: 0,
|
|
message: { role: 'assistant', content: result.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),
|
|
},
|
|
}));
|
|
} catch (err) {
|
|
console.error(`[${new Date().toISOString()}] ❌`, err.message);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { message: err.message, type: 'proxy_error' } }));
|
|
}
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, '127.0.0.1', () => {
|
|
console.log(`✅ claude-sub-proxy on http://127.0.0.1:${PORT} (OpenAI-compatible)`);
|
|
console.log(` Routes → Claude Code CLI (${CLAUDE_BIN}) using Pro subscription`);
|
|
});
|