Files
wp-agentic-writer/downloads/claude-proxy.js

279 lines
10 KiB
JavaScript

const express = require('express');
const { spawn } = require('child_process');
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const app = express();
app.use(express.json());
// Try multiple sources for Brave API Key (in order of priority):
// 1. Environment variable
// 2. .env file in proxy directory
// 3. ~/.claude/settings.json (Claude Code config)
function getBraveApiKey() {
// 1. Check environment variable first
if (process.env.BRAVE_SEARCH_API_KEY) {
return process.env.BRAVE_SEARCH_API_KEY;
}
// 2. Check .env file in proxy directory
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf8');
const match = envContent.match(/BRAVE_SEARCH_API_KEY\s*=\s*(.+)/m);
if (match) {
return match[1].trim();
}
}
// 3. Check Claude Code settings.json
const claudeSettingsPath = path.join(process.env.HOME || '/root', '.claude', 'settings.json');
if (fs.existsSync(claudeSettingsPath)) {
try {
const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
if (settings.env?.BRAVE_SEARCH_API_KEY) {
return settings.env.BRAVE_SEARCH_API_KEY;
}
} catch (e) {
// Ignore JSON parse errors
}
}
return '';
}
// Health check endpoint
app.get('/ping', (req, res) => {
const status = {
status: 'pong',
braveSearchConfigured: !!BRAVE_API_KEY,
timestamp: new Date().toISOString()
};
res.json(status);
});
// Main inference endpoint (OpenAI-compatible format)
app.post('/v1/messages', async (req, res) => {
const { messages, stream } = req.body;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
error: {
message: 'Invalid request: messages array required'
}
});
}
// Check if web search is requested (via X-Search-Enabled header)
const webSearchEnabled = req.headers['x-search-enabled'] === 'true';
const searchQuery = req.headers['x-search-query'] || '';
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('Request from:', req.ip);
console.log('Web Search:', webSearchEnabled ? 'ENABLED' : 'disabled');
if (searchQuery) {
console.log('Search Query:', searchQuery.substring(0, 100) + '...');
}
const braveApiKey = getBraveApiKey();
console.log('Brave API Key:', braveApiKey ? 'CONFIGURED' : 'NOT SET');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
// If web search is enabled and we have a query, fetch search results first
let searchContext = '';
if (webSearchEnabled && searchQuery && braveApiKey) {
console.log('Fetching web search results...');
try {
searchContext = await fetchBraveSearchResults(searchQuery, braveApiKey);
console.log('Search results fetched:', searchContext.length, 'chars');
} catch (err) {
console.error('Search error:', err.message);
}
}
// Build conversation context from messages array
// Include previous messages for context continuity
let conversationPrompt = '';
for (const msg of messages) {
const role = msg.role === 'assistant' ? 'Assistant' : 'User';
conversationPrompt += `${role}: ${msg.content}\n\n`;
}
let prompt = conversationPrompt.trim();
// Prepend search context if available
if (searchContext) {
prompt = `WEB SEARCH RESULTS:\n${searchContext}\n\n---\n\nUSER QUERY:\n${prompt}\n\nPlease answer based on the search results above when relevant.`;
}
console.log('Prompt length:', prompt.length, 'chars');
console.log('Prompt preview:', prompt.substring(0, 150) + '...');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
// Spawn Claude CLI process
const claude = spawn('claude', [], {
stdio: ['pipe', 'pipe', 'pipe']
});
let output = '';
let errorOutput = '';
claude.stdout.on('data', (data) => {
output += data.toString();
process.stdout.write('.');
});
claude.stderr.on('data', (data) => {
errorOutput += data.toString();
console.error('Claude stderr:', data.toString());
});
claude.on('close', (code) => {
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('Claude exit code:', code);
console.log('Response length:', output.length, 'chars');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
if (code !== 0 || !output.trim()) {
return res.status(500).json({
error: {
message: 'Claude CLI error',
details: errorOutput || 'No response from Claude'
}
});
}
// Return OpenAI-compatible response format
res.json({
id: 'local-' + Date.now(),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: 'claude-local',
choices: [{
index: 0,
message: {
role: 'assistant',
content: output.trim()
},
finish_reason: 'stop'
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
});
});
claude.on('error', (err) => {
console.error('Failed to spawn Claude CLI:', err);
res.status(500).json({
error: {
message: 'Failed to spawn Claude CLI',
details: err.message
}
});
});
// Send prompt to Claude after brief pause
setTimeout(() => {
claude.stdin.write(prompt + '\n');
claude.stdin.end();
}, 100);
});
/**
* Fetch search results from Brave Search API
*/
async function fetchBraveSearchResults(query, apiKey, count = 5) {
return new Promise((resolve, reject) => {
const encodedQuery = encodeURIComponent(query);
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodedQuery}&count=${count}`;
const options = {
headers: {
'Accept': 'application/json',
'X-Subscription-Token': apiKey
}
};
const protocol = url.startsWith('https') ? https : http;
const request = protocol.get(url, options, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
if (response.statusCode !== 200) {
return reject(new Error(`Brave API error: ${response.statusCode}`));
}
try {
const json = JSON.parse(data);
const results = json.web?.results || [];
if (results.length === 0) {
return resolve('No search results found.');
}
// Format results for LLM consumption
let formatted = 'Search Results:\n\n';
results.forEach((result, i) => {
formatted += `${i + 1}. **${result.title}**\n`;
formatted += ` URL: ${result.url}\n`;
if (result.description) {
formatted += ` Summary: ${result.description}\n`;
}
formatted += '\n';
});
resolve(formatted);
} catch (err) {
reject(new Error('Failed to parse Brave response'));
}
});
});
request.on('error', (err) => {
reject(err);
});
request.setTimeout(10000, () => {
request.destroy();
reject(new Error('Brave search timeout'));
});
});
}
const PORT = process.env.PORT || 8080;
app.listen(PORT, '0.0.0.0', () => {
const braveApiKey = getBraveApiKey();
console.log('═══════════════════════════════════════════════════');
console.log('🚀 Agentic Writer Local Backend v1.1.0');
console.log('═══════════════════════════════════════════════════');
console.log(`Local: http://localhost:${PORT}`);
console.log(`Network: http://YOUR-IP:${PORT}`);
console.log('');
console.log('Plugin Configuration:');
console.log(` Base URL: http://YOUR-IP:${PORT}`);
console.log(` API Key: dummy`);
console.log(` Model: claude-local`);
console.log('');
console.log('Brave Search:');
console.log(` API Key: ${braveApiKey ? 'CONFIGURED' : 'NOT SET'}`);
console.log('');
console.log('Web search works when Brave API key is found from:');
console.log(' 1. Environment: export BRAVE_SEARCH_API_KEY="key"');
console.log(' 2. .env file: BRAVE_SEARCH_API_KEY=key');
console.log(' 3. ~/.claude/settings.json env.BRAVE_SEARCH_API_KEY');
console.log('');
console.log('Restart proxy after adding key: ./stop-proxy.sh && ./start-proxy.sh');
console.log('');
console.log('Health check: GET /ping');
console.log('Inference: POST /v1/messages');
console.log('═══════════════════════════════════════════════════');
});