Files
dev/cli-proxy/claude-proxy.js
T

305 lines
10 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Claude CLI to OpenAI-compatible API Proxy
*
* This proxy allows @hanzo/dev to use Claude Code CLI as a backend.
* It accepts OpenAI-compatible chat completion requests and invokes
* the Claude CLI under the hood.
*
* Usage:
* node claude-proxy.js [--port 8080]
*
* Then configure ~/.code/config.toml:
* [model_providers.claude-cli]
* name = "Claude CLI"
* base_url = "http://localhost:8080/v1"
* wire_api = "chat"
*/
const http = require('http');
const { spawn } = require('child_process');
const PORT = parseInt(process.env.PORT || process.argv.find(a => a.startsWith('--port='))?.split('=')[1] || '8080');
/**
* Extract non-JSON text from Claude CLI output (filter telemetry)
*/
function extractResponse(output) {
const lines = output.split('\n');
const textLines = [];
let inJson = false;
let braceCount = 0;
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines at start
if (!trimmed && textLines.length === 0) continue;
// Track JSON blocks
if (trimmed.startsWith('{')) {
inJson = true;
braceCount = 1;
continue;
}
if (inJson) {
braceCount += (trimmed.match(/{/g) || []).length;
braceCount -= (trimmed.match(/}/g) || []).length;
if (braceCount <= 0) {
inJson = false;
}
continue;
}
// Skip lines that are just JSON artifacts (brackets, commas)
if (/^[\[\]\{\},\s]*$/.test(trimmed)) {
continue;
}
// Not in JSON block - this is actual content
if (trimmed && !trimmed.startsWith('}')) {
textLines.push(line);
}
}
// Clean up any trailing bracket artifacts
let result = textLines.join('\n').trim();
result = result.replace(/\s*[\[\]\{\}]+\s*$/g, '').trim();
return result;
}
/**
* Map model names to Claude CLI supported models
*/
function mapModel(model) {
const modelMap = {
'claude-cli': null, // Use default
'claude-opus': 'claude-opus-4-5-20251101',
'claude-sonnet': 'claude-sonnet-4-20250514',
'claude-haiku': 'claude-haiku-4-5-20251001',
'gpt-4': null, // Use default
'gpt-4o': null,
'gpt-4o-mini': null,
};
// If model is in map, use mapped value (null means use default)
if (model in modelMap) {
return modelMap[model];
}
// If model starts with 'claude-', pass it through
if (model && model.startsWith('claude-')) {
return model;
}
// Default: use Claude's default model
return null;
}
/**
* Invoke Claude CLI and return the response
*/
function invokeClaude(prompt, model) {
return new Promise((resolve, reject) => {
const args = ['-p', '--output-format', 'text'];
// Map model name to Claude CLI format
const mappedModel = mapModel(model);
if (mappedModel) {
args.push('--model', mappedModel);
}
// Create clean env without ANTHROPIC_API_KEY to force subscription mode
const cleanEnv = { ...process.env };
delete cleanEnv.ANTHROPIC_API_KEY;
delete cleanEnv.ANTHROPIC_AUTH_TOKEN;
cleanEnv.OTEL_SDK_DISABLED = 'true';
const claude = spawn('claude', args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: cleanEnv
});
let stdout = '';
let stderr = '';
claude.stdout.on('data', (data) => {
stdout += data.toString();
});
claude.stderr.on('data', (data) => {
stderr += data.toString();
});
claude.on('close', (code) => {
if (code !== 0 && !stdout) {
reject(new Error(`Claude CLI exited with code ${code}: ${stderr}`));
} else {
const response = extractResponse(stdout);
resolve(response || stdout);
}
});
claude.on('error', (err) => {
reject(err);
});
// Send prompt via stdin
claude.stdin.write(prompt);
claude.stdin.end();
});
}
/**
* Handle incoming HTTP requests
*/
async function handleRequest(req, res) {
const url = new URL(req.url, `http://localhost:${PORT}`);
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// Health check
if (url.pathname === '/health' || url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', provider: 'claude-cli' }));
return;
}
// Chat completions endpoint
if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const request = JSON.parse(body);
// Extract prompt from messages - combine all for context
const messages = request.messages || [];
const model = request.model;
// Build a combined prompt with role prefixes for context
let prompt = messages.map(msg => {
const role = msg.role || 'user';
const content = msg.content || '';
if (role === 'system') {
return `[System]: ${content}`;
} else if (role === 'assistant') {
return `[Assistant]: ${content}`;
} else {
return content; // User messages without prefix
}
}).join('\n\n');
console.log(`[${new Date().toISOString()}] Request: model=${model}, prompt=${prompt.substring(0, 50)}...`);
// Invoke Claude CLI
const response = await invokeClaude(prompt, model);
console.log(`[${new Date().toISOString()}] Response: ${response.substring(0, 100)}...`);
// Return OpenAI-compatible response
const completion = {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model || 'claude-cli',
choices: [{
index: 0,
message: {
role: 'assistant',
content: response
},
finish_reason: 'stop'
}],
usage: {
prompt_tokens: Math.ceil(prompt.length / 4),
completion_tokens: Math.ceil(response.length / 4),
total_tokens: Math.ceil((prompt.length + response.length) / 4)
}
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(completion));
} catch (error) {
console.error(`[${new Date().toISOString()}] Error:`, error.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
message: error.message,
type: 'server_error',
code: 'internal_error'
}
}));
}
});
return;
}
// Models endpoint (for compatibility)
if (url.pathname === '/v1/models' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [
{ id: 'claude-cli', object: 'model', created: Date.now(), owned_by: 'anthropic' },
{ id: 'claude-opus', object: 'model', created: Date.now(), owned_by: 'anthropic' },
{ id: 'claude-sonnet', object: 'model', created: Date.now(), owned_by: 'anthropic' },
{ id: 'claude-haiku', object: 'model', created: Date.now(), owned_by: 'anthropic' }
]
}));
return;
}
// 404 for unknown endpoints
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Not found', type: 'not_found' } }));
}
// Create server
const server = http.createServer(handleRequest);
server.listen(PORT, () => {
console.log(`
╔══════════════════════════════════════════════════════════════╗
║ Claude CLI → OpenAI API Proxy ║
╠══════════════════════════════════════════════════════════════╣
║ Listening on: http://localhost:${PORT} ║
║ Endpoint: http://localhost:${PORT}/v1/chat/completions ║
╠══════════════════════════════════════════════════════════════╣
║ Configure ~/.code/config.toml: ║
║ ║
║ [model_providers.claude-cli] ║
║ name = "Claude CLI" ║
║ base_url = "http://localhost:${PORT}/v1" ║
║ wire_api = "chat" ║
║ ║
║ Then: code -c 'model_provider="claude-cli"' ║
╚══════════════════════════════════════════════════════════════╝
`);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down...');
server.close(() => {
process.exit(0);
});
});