#!/usr/bin/env node /** * Multi-CLI to OpenAI-compatible API Proxy * * Supports multiple AI CLI backends (uses subscription/login, NOT API keys): * - Claude Code CLI (claude) - Anthropic subscription * - Codex CLI (codex) - OpenAI subscription * - Gemini CLI (gemini) - Google account * - Mistral Vibe CLI (vibe) - Mistral subscription * - Qwen CLI (qwen) - Alibaba account * - Ollama (ollama) - Local models * * Usage: * node multi-cli-proxy.js [--port 9999] * * Configure ~/.code/config.toml with any supported provider. */ const http = require('http'); const { spawn } = require('child_process'); const PORT = parseInt(process.env.PORT || process.argv.find(a => a.startsWith('--port='))?.split('=')[1] || '9999'); /** * API keys to clean per backend (forces subscription/login mode) */ const API_KEYS_TO_CLEAN = { claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'], codex: ['OPENAI_API_KEY', 'OPENAI_ORG_ID'], gemini: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'], vibe: ['MISTRAL_API_KEY'], qwen: ['DASHSCOPE_API_KEY', 'QWEN_API_KEY'], }; /** * CLI Backend Configurations * All backends use subscription/login mode by default (API keys removed from env) */ const CLI_BACKENDS = { // Claude Code CLI - uses Anthropic subscription 'claude': { command: 'claude', args: (prompt, model) => { const args = ['-p', '--output-format', 'text']; if (model && model !== 'claude-cli' && model.startsWith('claude-')) { args.push('--model', model); } return args; }, env: { OTEL_SDK_DISABLED: 'true' }, inputMode: 'stdin', models: ['claude-cli', 'claude-opus', 'claude-sonnet', 'claude-haiku'], }, // Codex CLI (OpenAI) - uses OpenAI subscription 'codex': { command: 'codex', args: (prompt, model) => { const args = ['-q', '--full-auto']; // -q for quiet, final output only if (model && model !== 'codex' && model.startsWith('codex-')) { args.push('-m', model); } args.push(prompt); return args; }, env: { OTEL_SDK_DISABLED: 'true' }, inputMode: 'args', models: ['codex', 'codex-mini', 'codex-mini-latest', 'gpt-4o', 'gpt-4o-mini', 'o1', 'o3-mini'], }, // Google Gemini CLI - uses Google account 'gemini': { command: 'gemini', args: (prompt, model) => { const args = ['-p', prompt, '-y']; // -y for auto-approve if (model && model !== 'gemini' && model.startsWith('gemini-')) { args.push('-m', model); } return args; }, env: {}, inputMode: 'args', models: ['gemini', 'gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-pro'], }, // Mistral Vibe CLI - uses Mistral subscription 'vibe': { command: 'vibe', args: (prompt, model) => ['-p', prompt, '--output', 'text', '--auto-approve'], env: {}, inputMode: 'args', models: ['vibe', 'mistral', 'mistral-large', 'mistral-small'], }, // Qwen CLI - uses Alibaba account 'qwen': { command: 'qwen', args: (prompt, model) => ['-p', prompt], env: { OTEL_SDK_DISABLED: 'true' }, inputMode: 'args', models: ['qwen', 'qwen-cli', 'qwen-plus', 'qwen-turbo', 'qwen3'], }, // Ollama (local models) - no API keys needed 'ollama': { command: 'ollama', args: (prompt, model) => ['run', model || 'llama3.2', prompt], env: {}, inputMode: 'args', models: ['ollama', 'llama3.2', 'llama3.1', 'codellama', 'mixtral', 'phi3'], }, // GitHub Copilot - uses GitHub subscription via gh extension 'copilot': { command: 'gh', args: (prompt, model) => ['copilot', 'suggest', '--type', 'shell', prompt], env: {}, inputMode: 'args', models: ['copilot', 'gh-copilot', 'github-copilot'], }, }; /** * Map model name to CLI backend */ function getBackend(model) { if (!model) return { ...CLI_BACKENDS['claude'], name: 'claude' }; // Direct backend match for (const [name, backend] of Object.entries(CLI_BACKENDS)) { if (backend.models.includes(model)) { return { ...backend, name }; } } // Prefix matching if (model.startsWith('claude')) return { ...CLI_BACKENDS['claude'], name: 'claude' }; if (model.startsWith('codex') || model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) { return { ...CLI_BACKENDS['codex'], name: 'codex' }; } if (model.startsWith('gemini')) return { ...CLI_BACKENDS['gemini'], name: 'gemini' }; if (model.startsWith('vibe') || model.startsWith('mistral')) return { ...CLI_BACKENDS['vibe'], name: 'vibe' }; if (model.startsWith('qwen')) return { ...CLI_BACKENDS['qwen'], name: 'qwen' }; if (model.startsWith('llama') || model.startsWith('ollama') || model.startsWith('phi')) { return { ...CLI_BACKENDS['ollama'], name: 'ollama' }; } if (model.startsWith('copilot') || model.startsWith('gh-copilot')) { return { ...CLI_BACKENDS['copilot'], name: 'copilot' }; } // Default to Claude return { ...CLI_BACKENDS['claude'], name: 'claude' }; } /** * Extract clean text from CLI output (filter telemetry/JSON artifacts) */ 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(); if (!trimmed && textLines.length === 0) continue; 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; } if (/^[\[\]\{\},\s]*$/.test(trimmed)) continue; if (trimmed && !trimmed.startsWith('}')) { textLines.push(line); } } let result = textLines.join('\n').trim(); result = result.replace(/\s*[\[\]\{\}]+\s*$/g, '').trim(); return result; } /** * Invoke CLI backend and return response */ function invokeCLI(prompt, model) { return new Promise((resolve, reject) => { const backend = getBackend(model); const args = backend.args(prompt, model); console.log(`[${new Date().toISOString()}] Backend: ${backend.name}, Command: ${backend.command} ${args.slice(0, 2).join(' ')}...`); // Build environment - clean API keys to force subscription/login mode let spawnEnv = { ...process.env, ...backend.env }; const keysToClean = API_KEYS_TO_CLEAN[backend.name] || []; for (const key of keysToClean) { delete spawnEnv[key]; } const proc = spawn(backend.command, args, { stdio: ['pipe', 'pipe', 'pipe'], env: spawnEnv }); let stdout = ''; let stderr = ''; proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('close', (code) => { if (code !== 0 && !stdout) { reject(new Error(`${backend.name} CLI exited with code ${code}: ${stderr}`)); } else { const response = extractResponse(stdout); resolve(response || stdout.trim()); } }); proc.on('error', (err) => { reject(new Error(`Failed to spawn ${backend.name}: ${err.message}`)); }); // Send prompt via stdin if backend uses stdin mode if (backend.inputMode === 'stdin') { proc.stdin.write(prompt); proc.stdin.end(); } }); } /** * Build prompt from messages array */ function buildPrompt(messages) { return messages.map(msg => { const role = msg.role || 'user'; const content = msg.content || ''; if (role === 'system') return `[System]: ${content}`; if (role === 'assistant') return `[Assistant]: ${content}`; return content; }).join('\n\n'); } /** * Handle HTTP requests */ async function handleRequest(req, res) { const url = new URL(req.url, `http://localhost:${PORT}`); // CORS 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', backends: Object.keys(CLI_BACKENDS), default: 'claude' })); return; } // Chat completions 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); const messages = request.messages || []; const model = request.model; const prompt = buildPrompt(messages); console.log(`[${new Date().toISOString()}] Request: model=${model}, prompt=${prompt.substring(0, 50)}...`); const response = await invokeCLI(prompt, model); console.log(`[${new Date().toISOString()}] Response: ${response.substring(0, 100)}...`); 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 if (url.pathname === '/v1/models' && req.method === 'GET') { const allModels = []; for (const [name, backend] of Object.entries(CLI_BACKENDS)) { for (const model of backend.models) { allModels.push({ id: model, object: 'model', created: Date.now(), owned_by: name }); } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ object: 'list', data: allModels })); return; } // 404 res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Not found', type: 'not_found' } })); } // Start server const server = http.createServer(handleRequest); server.listen(PORT, () => { const backends = Object.keys(CLI_BACKENDS).join(', '); console.log(` ╔══════════════════════════════════════════════════════════════════╗ ║ Multi-CLI → OpenAI API Proxy (Subscription Mode) ║ ╠══════════════════════════════════════════════════════════════════╣ ║ Listening: http://localhost:${PORT} ║ ║ Endpoint: http://localhost:${PORT}/v1/chat/completions ║ ║ Backends: ${backends.padEnd(48)}║ ╠══════════════════════════════════════════════════════════════════╣ ║ All CLIs use subscription/login (API keys are stripped): ║ ║ claude-cli, claude-opus → Claude (Anthropic subscription) ║ ║ codex, gpt-4o, o1 → Codex (OpenAI subscription) ║ ║ gemini, gemini-2.5-pro → Gemini (Google account) ║ ║ vibe, mistral → Vibe (Mistral subscription) ║ ║ qwen, qwen-plus → Qwen (Alibaba account) ║ ║ ollama, llama3.2 → Ollama (local, no keys needed) ║ ╚══════════════════════════════════════════════════════════════════╝ `); }); process.on('SIGINT', () => { console.log('\nShutting down...'); server.close(() => process.exit(0)); });