diff --git a/README.md b/README.md index ebedc8f..1024b52 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A powerful CLI tool and Node.js module for sending mass lead emails with AI-powe - šŸ“§ **Mass Email Campaigns** - Send personalized emails to hundreds of leads - šŸ“ž **Voice AI Cold-calling** - Automated cold calling with Bland.ai integration -- šŸ¤– **AI Personalization** - OpenAI-powered template customization +- šŸ¤– **AI Personalization** - template customization on c0mpute, with OpenAI as the fallback - šŸ“Š **CSV Processing** - Import leads from CSV files with validation - šŸŽÆ **Smart Templates** - 10 pre-built sales email templates - šŸ“ˆ **Batch Processing** - Configurable batch sizes with rate limiting @@ -43,7 +43,13 @@ Create a `.env` file with your API keys: MAILGUN_API_KEY=your_mailgun_api_key_here MAILGUN_DOMAIN=your_mailgun_domain_here -# OpenAI Configuration (Required for AI personalization) +# AI personalization runs on c0mpute first (no key needed: 10 free calls a day; +# an Infernet account key ik_live_... lifts that). C0MPUTE_DISABLED=1 turns it off. +C0MPUTE_API_KEY=your_infernet_account_key +# c0mpute is slow: 60 to 240 s per lead is normal. Whole-call cap: +C0MPUTE_TIMEOUT_MS=300000 + +# OpenAI (fallback when c0mpute fails) OPENAI_API_KEY=your_openai_api_key_here # Bland.ai Configuration (Required for voice calling) @@ -445,7 +451,8 @@ MAILGUN_API_KEY=your_mailgun_api_key MAILGUN_DOMAIN=your_mailgun_domain MAILGUN_BASE_URL=https://api.mailgun.net -# OpenAI Configuration (AI Personalization) +# AI Personalization: c0mpute first, OpenAI fallback +C0MPUTE_API_KEY=your_infernet_account_key OPENAI_API_KEY=your_openai_api_key OPENAI_MODEL=gpt-4o-mini OPENAI_MAX_TOKENS=500 @@ -576,7 +583,7 @@ lead-generator/ ā”œā”€ā”€ src/ │ ā”œā”€ā”€ templates/ # Email templates │ ā”œā”€ā”€ csv-parser.js # CSV processing -│ ā”œā”€ā”€ ai-service.js # OpenAI integration +│ ā”œā”€ā”€ ai-service.js # AI personalization (c0mpute, OpenAI fallback) │ ā”œā”€ā”€ email-service.js # Mailgun integration │ └── voice-service.js # Voice AI stub ā”œā”€ā”€ test/ # Test files diff --git a/index.js b/index.js index f56ef56..2f53491 100644 --- a/index.js +++ b/index.js @@ -52,7 +52,16 @@ export { getAllTones } from './src/email-templates/index.js'; -// Import for internal use +// Import for internal use (LeadGenerator calls these; `export ... from` +// does not bind them in this module) +import { processLeadsFromCSV } from './src/csv-parser.js'; +import { + personalizeTemplate, + batchPersonalize, + getPersonalizationStats +} from './src/ai-service.js'; +import { sendBatchEmails } from './src/email-service.js'; +import { isC0mputeDisabled } from './src/c0mpute.js'; import { templates as emailTemplates, getTemplateById as getEmailTemplateById, @@ -98,7 +107,9 @@ export class LeadGenerator { apiKey: config.openaiApiKey || process.env.OPENAI_API_KEY, model: config.openaiModel || process.env.OPENAI_MODEL || 'gpt-4o-mini', maxTokens: config.openaiMaxTokens || parseInt(process.env.OPENAI_MAX_TOKENS) || 500, - temperature: config.openaiTemperature || parseFloat(process.env.OPENAI_TEMPERATURE) || 0.7 + temperature: config.openaiTemperature || parseFloat(process.env.OPENAI_TEMPERATURE) || 0.7, + // c0mpute is tried first; key defaults to C0MPUTE_API_KEY + c0mputeApiKey: config.c0mputeApiKey || process.env.C0MPUTE_API_KEY }, // Sender information @@ -237,8 +248,9 @@ export class LeadGenerator { errors.push('Sender name is required'); } - // OpenAI is optional but warn if personalization is enabled - if (this.config.campaign.enablePersonalization && !this.config.openai.apiKey) { + // Personalization runs on c0mpute with no key; OpenAI is only required + // when C0MPUTE_DISABLED turns c0mpute off + if (this.config.campaign.enablePersonalization && !this.config.openai.apiKey && isC0mputeDisabled()) { errors.push('OpenAI API key is required for AI personalization (or disable personalization)'); } diff --git a/package.json b/package.json index 4e46be9..2cbb6e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/lead-generator", - "version": "1.2.4", + "version": "1.3.0", "description": "A CLI tool for sending mass lead emails to prospective clients from CSV files with AI-powered personalization", "type": "module", "main": "index.js", @@ -61,6 +61,7 @@ "packageManager": "pnpm@10.6.5", "dependencies": { "@hono/node-server": "^1.14.4", + "@profullstack/ai-providers": "0.1.2", "ansi-colors": "^4.1.3", "cli-progress": "^3.12.0", "csv-parser": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9465206..e38d655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hono/node-server': specifier: ^1.14.4 version: 1.14.4(hono@4.7.11) + '@profullstack/ai-providers': + specifier: 0.1.2 + version: 0.1.2 ansi-colors: specifier: ^4.1.3 version: 4.1.3 @@ -193,6 +196,11 @@ packages: '@types/node': optional: true + '@profullstack/ai-providers@0.1.2': + resolution: {integrity: sha512-nOcKaOhfcDM+RNruA4+znva5xXxI4HF77iqUCgieuou2w8yY8LIF2p5ajSVD4kzFtfIUjC7GhGV8eyOrliHg1w==} + engines: {node: '>=20'} + hasBin: true + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -1030,6 +1038,8 @@ snapshots: optionalDependencies: '@types/node': 18.19.111 + '@profullstack/ai-providers@0.1.2': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 diff --git a/src/ai-service.js b/src/ai-service.js index 250c4fd..a1022ca 100644 --- a/src/ai-service.js +++ b/src/ai-service.js @@ -1,10 +1,12 @@ /** * AI Service Module - * Handles OpenAI integration for template personalization + * Template personalization: c0mpute first (see ./c0mpute.js), OpenAI as the + * fallback when a key is set. */ import OpenAI from 'openai'; import dotenv from 'dotenv'; +import { createC0mpute } from './c0mpute.js'; dotenv.config(); @@ -21,14 +23,24 @@ export class AIService { ...config }; - if (!this.config.apiKey) { + // c0mpute first. `c0mpute: false` skips it; a router can be passed in. + this.c0mpute = config.c0mpute === false + ? null + : config.c0mpute || createC0mpute({ + apiKey: config.c0mputeApiKey, + timeoutMs: config.c0mputeTimeoutMs + }); + + if (!this.config.apiKey && !config.client && !this.c0mpute) { throw new Error('OpenAI API key is required'); } // Use provided client for testing, otherwise create new OpenAI client - this.client = config.client || new OpenAI({ - apiKey: this.config.apiKey - }); + this.client = config.client || (this.config.apiKey + ? new OpenAI({ + apiKey: this.config.apiKey + }) + : null); } /** @@ -38,13 +50,34 @@ export class AIService { * @returns {Promise} AI response */ async generateCompletion(prompt, options = {}) { + const system = 'You are a professional sales email personalization assistant. Generate personalized, professional, and engaging content based on the provided information.'; + + if (this.c0mpute) { + try { + const { object } = await this.c0mpute.generateObject({ + system, + user: prompt, + maxTokens: options.maxTokens || this.config.maxTokens, + temperature: options.temperature || this.config.temperature, + feature: 'lead-generator' + }); + this.lastProvider = 'c0mpute'; + return JSON.stringify(object); + } catch (error) { + if (!this.client) { + throw new Error(`AI completion failed: ${error.message}`); + } + console.warn(`c0mpute failed, falling back to OpenAI: ${error.message}`); + } + } + try { const response = await this.client.chat.completions.create({ model: this.config.model, messages: [ { role: 'system', - content: 'You are a professional sales email personalization assistant. Generate personalized, professional, and engaging content based on the provided information.' + content: system }, { role: 'user', @@ -56,6 +89,7 @@ export class AIService { response_format: { type: 'json_object' } }); + this.lastProvider = 'openai'; return response.choices[0]?.message?.content; } catch (error) { if (error.status === 429) { diff --git a/src/c0mpute.js b/src/c0mpute.js new file mode 100644 index 0000000..200508f --- /dev/null +++ b/src/c0mpute.js @@ -0,0 +1,49 @@ +/** + * c0mpute first, the library's own vendor code second. + * + * Text generation goes to the c0mpute peer network through + * @profullstack/ai-providers. When c0mpute fails (no node, timeout, free tier + * used up), the caller runs its existing OpenAI code exactly as before, so a + * failure on both looks the same as a failure did before c0mpute. + * + * C0MPUTE_API_KEY Infernet account key (ik_live_...). Without one the + * network still answers, on a free tier of 10 calls a day. + * C0MPUTE_TIMEOUT_MS whole call, default 300000 (c0mpute runs at a few + * tokens a second; 60 to 240 s per call is normal) + * C0MPUTE_DISABLED 1 skips c0mpute entirely + */ +import { createAiProviders } from '@profullstack/ai-providers'; + +const ON = /^(1|true|yes|on)$/i; +const routers = new Map(); + +/** True when C0MPUTE_DISABLED turns c0mpute off. */ +export function isC0mputeDisabled(env = process.env) { + return ON.test(env.C0MPUTE_DISABLED ?? ''); +} + +/** + * A c0mpute-only router (no paid fallback inside it: the library keeps its + * own), or null when c0mpute is disabled. One router per key and timeout, so + * the concurrency cap and parking cover every call in the process. + * @param {object} [opts] + * @param {string} [opts.apiKey] overrides C0MPUTE_API_KEY + * @param {number} [opts.timeoutMs] overrides C0MPUTE_TIMEOUT_MS + * @param {Record} [opts.env] + */ +export function createC0mpute({ apiKey, timeoutMs, env = process.env } = {}) { + if (isC0mputeDisabled(env)) return null; + const key = apiKey || env.C0MPUTE_API_KEY || env.INFERNET_API_KEY || ''; + const id = `${key}\0${timeoutMs ?? ''}`; + if (!routers.has(id)) { + routers.set( + id, + createAiProviders({ + env: { ...env, C0MPUTE_API_KEY: key }, + paidFallback: false, + c0mpute: timeoutMs ? { timeoutMs } : {}, + }), + ); + } + return routers.get(id); +} diff --git a/src/cli-setup.js b/src/cli-setup.js index 57a0033..86fe0ef 100644 --- a/src/cli-setup.js +++ b/src/cli-setup.js @@ -108,15 +108,14 @@ export async function promptForConfig() { } ]); - console.log('\nšŸ¤– AI Configuration (OpenAI)'); + console.log('\nšŸ¤– AI Configuration (c0mpute first, OpenAI fallback)'); console.log('----------------------------'); const aiAnswers = await inquirer.prompt([ { type: 'input', name: 'openaiApiKey', - message: 'OpenAI API Key:', - validate: (input) => input.trim() ? true : 'OpenAI API key is required' + message: 'OpenAI API Key (fallback; leave blank to use c0mpute only):' }, { type: 'list', diff --git a/src/config-manager.js b/src/config-manager.js index c3a9b05..0d9d811 100644 --- a/src/config-manager.js +++ b/src/config-manager.js @@ -6,6 +6,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +import { isC0mputeDisabled } from './c0mpute.js'; /** * Get the path to the configuration file @@ -78,7 +79,8 @@ export function validateConfig(config) { errors.push('Mailgun domain is required'); } - if (!config?.ai?.openai?.apiKey || config.ai.openai.apiKey.trim() === '') { + // c0mpute needs no key, so OpenAI is only required when c0mpute is off + if (isC0mputeDisabled() && (!config?.ai?.openai?.apiKey || config.ai.openai.apiKey.trim() === '')) { errors.push('OpenAI API key is required'); } diff --git a/test/ai-service.test.js b/test/ai-service.test.js index 494e775..98b1694 100644 --- a/test/ai-service.test.js +++ b/test/ai-service.test.js @@ -7,6 +7,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { personalizeTemplate, generatePlaceholderValues, AIService } from '../src/ai-service.js'; +// The OpenAI tests below exercise the fallback path; c0mpute is covered in +// its own block with a stub router. +process.env.C0MPUTE_DISABLED = '1'; + describe('AI Service', () => { let aiService; let mockOpenAI; @@ -195,9 +199,15 @@ Best, }); it('should validate required configuration', () => { - expect(() => { - new AIService({}); - }).to.throw('OpenAI API key is required'); + const saved = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + try { + expect(() => { + new AIService({}); + }).to.throw('OpenAI API key is required'); + } finally { + if (saved !== undefined) process.env.OPENAI_API_KEY = saved; + } }); it('should create prompt for template personalization', () => { @@ -266,4 +276,45 @@ Best, expect(result.body).to.equal('Hi John.'); }); }); -}); \ No newline at end of file + + describe('c0mpute first', () => { + it('does not need an OpenAI key when c0mpute is available', () => { + const service = new AIService({ apiKey: '', c0mpute: { generateObject: sinon.stub() } }); + expect(service.client).to.equal(null); + }); + + it('returns the c0mpute JSON and never calls OpenAI', async () => { + const c0mpute = { generateObject: sinon.stub().resolves({ object: { FIRST_NAME: 'Ada' }, provider: 'c0mpute' }) }; + const service = new AIService({ apiKey: 'sk-test', client: mockOpenAI, c0mpute }); + + const text = await service.generateCompletion('prompt'); + + expect(JSON.parse(text)).to.deep.equal({ FIRST_NAME: 'Ada' }); + expect(service.lastProvider).to.equal('c0mpute'); + expect(mockOpenAI.chat.completions.create.called).to.equal(false); + }); + + it('falls back to OpenAI when c0mpute fails', async () => { + const c0mpute = { generateObject: sinon.stub().rejects(new Error('no node')) }; + mockOpenAI.chat.completions.create.resolves({ choices: [{ message: { content: '{"FIRST_NAME":"Bo"}' } }] }); + const service = new AIService({ apiKey: 'sk-test', client: mockOpenAI, c0mpute }); + + const text = await service.generateCompletion('prompt'); + + expect(JSON.parse(text)).to.deep.equal({ FIRST_NAME: 'Bo' }); + expect(service.lastProvider).to.equal('openai'); + }); + + it('throws the c0mpute error when there is no OpenAI fallback', async () => { + const c0mpute = { generateObject: sinon.stub().rejects(new Error('no node')) }; + const service = new AIService({ apiKey: '', c0mpute }); + + try { + await service.generateCompletion('prompt'); + expect.fail('should have thrown'); + } catch (error) { + expect(error.message).to.equal('AI completion failed: no node'); + } + }); + }); +});