Files
openclaw-backups/scripts/claude-sub-proxy.js

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`);
});