@amg555/frameforge-mcp 0.0.0-stage → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
  2. package/.claude-plugin/marketplace.json +17 -0
  3. package/.gitattributes +19 -0
  4. package/.mcp.json +45 -0
  5. package/CHANGELOG.md +431 -0
  6. package/CLAUDE.md +278 -0
  7. package/CONTRIBUTING.md +112 -0
  8. package/LICENSE +21 -0
  9. package/Procfile +1 -0
  10. package/README.md +359 -2
  11. package/SECURITY.md +71 -0
  12. package/START_HERE.md +144 -0
  13. package/assets/metadata/assets.json +5 -0
  14. package/assets/metadata/embeddings.json +5 -0
  15. package/install.sh +64 -0
  16. package/package.json +58 -4
  17. package/src/animation/animation-system.js +229 -0
  18. package/src/asset-library/README.md +188 -0
  19. package/src/asset-library/asset-library.js +639 -0
  20. package/src/audio-mixer/mixer.js +187 -0
  21. package/src/cli/doctor.js +173 -0
  22. package/src/cli/make.js +655 -0
  23. package/src/cli/setup-keys.js +378 -0
  24. package/src/config/load-config.js +72 -0
  25. package/src/fact-checker/checker.js +384 -0
  26. package/src/fact-checker/review-gate.js +306 -0
  27. package/src/fact-checker/validation-set.js +1078 -0
  28. package/src/mcp-server/server.js +1299 -0
  29. package/src/mcp-server/stdio.js +87 -0
  30. package/src/pipeline/artifact-manifest.js +323 -0
  31. package/src/pipeline/artifact-retention.js +189 -0
  32. package/src/pipeline/external-adapter.js +366 -0
  33. package/src/pipeline/providers.js +853 -0
  34. package/src/pipeline/retry-policy.js +134 -0
  35. package/src/pipeline/state-machine.js +297 -0
  36. package/src/pipeline/telemetry.js +162 -0
  37. package/src/quality-gate/gate.js +229 -0
  38. package/src/schema/scene.schema.json +101 -0
  39. package/src/shorts-extractor/extractor.js +343 -0
  40. package/src/utils/log-redaction.js +274 -0
  41. package/src/video-renderer/renderer.js +364 -0
  42. package/src/worker/server-render-free.js +420 -0
  43. package/src/worker/server.js +328 -0
  44. package/src/workflows/assets.js +131 -0
  45. package/src/workflows/ingest.js +299 -0
  46. package/src/workflows/packaging.js +156 -0
  47. package/src/workflows/publishing.js +206 -0
  48. package/src/workflows/render.js +102 -0
  49. package/src/workflows/research.js +151 -0
  50. package/src/workflows/script.js +210 -0
  51. package/src/workflows/shorts.js +113 -0
@@ -0,0 +1,853 @@
1
+ /**
2
+ * Provider abstraction layer — LLM, TTS, Image generation.
3
+ *
4
+ * Every provider is stateless. Swap by setting PROVIDER_* env vars.
5
+ * All providers share the same interface — pipeline code never names a vendor.
6
+ *
7
+ * ── LLM providers ──────────────────────────────────────────────────────────
8
+ *
9
+ * PROVIDER_LLM=groq Free tier, fastest. llama-3.3-70b-versatile
10
+ * GROQ_API_KEY → console.groq.com
11
+ *
12
+ * PROVIDER_LLM=openrouter Route to ANY model (Claude 3.5 Sonnet, GPT-4o,
13
+ * Gemini 2.0 Flash, Llama 3.3 70B, Mistral Large,
14
+ * DeepSeek R1, Qwen 2.5 72B, …) via one API key.
15
+ * OPENROUTER_API_KEY → openrouter.ai
16
+ * LLM_MODEL=anthropic/claude-sonnet-4-5 ← override
17
+ *
18
+ * PROVIDER_LLM=anthropic Claude Sonnet 4.5 / Haiku / Opus direct.
19
+ * ANTHROPIC_API_KEY → console.anthropic.com
20
+ *
21
+ * PROVIDER_LLM=gemini Google Gemini 2.0 Flash / 2.5 Pro direct.
22
+ * GEMINI_API_KEY → aistudio.google.com
23
+ *
24
+ * PROVIDER_LLM=mistral Mistral Large 2 / Codestral / Nemo.
25
+ * MISTRAL_API_KEY → console.mistral.ai
26
+ *
27
+ * PROVIDER_LLM=together Together AI — Llama 3.3 70B, Mixtral, DBRX, etc.
28
+ * TOGETHER_API_KEY → api.together.ai
29
+ *
30
+ * PROVIDER_LLM=cerebras Cerebras — llama3.3-70b at 2000+ tok/s.
31
+ * CEREBRAS_API_KEY → cloud.cerebras.ai
32
+ *
33
+ * PROVIDER_LLM=openai OpenAI GPT-4o / o1 / o3-mini.
34
+ * OPENAI_API_KEY → platform.openai.com
35
+ *
36
+ * PROVIDER_LLM=ollama Fully local — no key, any Ollama model.
37
+ * OLLAMA_BASE_URL + OLLAMA_MODEL
38
+ *
39
+ * PROVIDER_LLM=webhook Any OpenAI-compatible endpoint (LM Studio,
40
+ * vLLM, LocalAI, your own proxy, VoxForge, …).
41
+ * WEBHOOK_LLM_URL (required)
42
+ * WEBHOOK_LLM_HEADERS, WEBHOOK_LLM_RESPONSE_PATH
43
+ *
44
+ * LLM_MODEL=<model-id> Override the default model for any provider.
45
+ * LLM_BASE_URL=<url> Override the endpoint URL for any provider.
46
+ *
47
+ * ── TTS providers ──────────────────────────────────────────────────────────
48
+ * PROVIDER_TTS=edge-tts | elevenlabs | azure | openai | webhook
49
+ *
50
+ * ── Image providers ────────────────────────────────────────────────────────
51
+ * PROVIDER_IMAGE=pollinations | falai | replicate | dalle | webhook
52
+ */
53
+
54
+ 'use strict';
55
+
56
+ const { spawn } = require('child_process');
57
+ const fs = require('fs').promises;
58
+ const path = require('path');
59
+ const https = require('https');
60
+ const http = require('http');
61
+ const crypto = require('crypto');
62
+
63
+ // ── Shared retry helper ───────────────────────────────────────────────────
64
+
65
+ async function withRetry(fn, { maxAttempts = 3, baseDelayMs = 2000, label = '' } = {}) {
66
+ let lastErr;
67
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
68
+ try {
69
+ return await fn();
70
+ } catch (err) {
71
+ lastErr = err;
72
+ if (attempt < maxAttempts) {
73
+ const delay = baseDelayMs * Math.pow(2, attempt - 1);
74
+ if (process.env.DEBUG_PROVIDERS) console.warn(`[${label}] attempt ${attempt} failed: ${err.message} — retry in ${delay}ms`);
75
+ await new Promise(r => setTimeout(r, delay));
76
+ }
77
+ }
78
+ }
79
+ throw lastErr;
80
+ }
81
+
82
+ // ── HTTP helpers ──────────────────────────────────────────────────────────
83
+
84
+ function httpPost(url, body, headers = {}) {
85
+ return new Promise((resolve, reject) => {
86
+ const parsed = new URL(url);
87
+ const lib = parsed.protocol === 'https:' ? https : http;
88
+ const bodyBuf = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
89
+ const options = {
90
+ hostname: parsed.hostname,
91
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
92
+ path: parsed.pathname + parsed.search,
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json', 'Content-Length': bodyBuf.length, ...headers },
95
+ timeout: 30000,
96
+ };
97
+ const req = lib.request(options, res => {
98
+ const chunks = [];
99
+ res.on('data', c => chunks.push(c));
100
+ res.on('end', () => {
101
+ const raw = Buffer.concat(chunks).toString();
102
+ if (res.statusCode >= 400) {
103
+ reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 200)}`));
104
+ } else {
105
+ try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
106
+ }
107
+ });
108
+ });
109
+ req.on('error', reject);
110
+ req.on('timeout', () => { req.destroy(); reject(new Error(`httpPost timeout: ${url}`)); });
111
+ req.write(bodyBuf);
112
+ req.end();
113
+ });
114
+ }
115
+
116
+ /** HTTP GET — used for Replicate polling and IBM IAM token checks. */
117
+ function httpGet(url, headers = {}) {
118
+ return new Promise((resolve, reject) => {
119
+ const parsed = new URL(url);
120
+ const lib = parsed.protocol === 'https:' ? https : http;
121
+ const options = {
122
+ hostname: parsed.hostname,
123
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
124
+ path: parsed.pathname + parsed.search,
125
+ method: 'GET',
126
+ headers: { 'Content-Type': 'application/json', ...headers },
127
+ timeout: 30000,
128
+ };
129
+ const req = lib.request(options, res => {
130
+ const chunks = [];
131
+ res.on('data', c => chunks.push(c));
132
+ res.on('end', () => {
133
+ const raw = Buffer.concat(chunks).toString();
134
+ if (res.statusCode >= 400) {
135
+ reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 200)}`));
136
+ } else {
137
+ try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
138
+ }
139
+ });
140
+ });
141
+ req.on('error', reject);
142
+ req.on('timeout', () => { req.destroy(); reject(new Error(`httpGet timeout: ${url}`)); });
143
+ req.end();
144
+ });
145
+ }
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // LLM PROVIDER
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Current top models per provider (Nov 2026).
153
+ * All overridable via LLM_MODEL env var. Keep ONLY providers that exist today.
154
+ *
155
+ * Model update guide:
156
+ * groq: console.groq.com/docs/models
157
+ * openrouter: openrouter.ai/models
158
+ * anthropic: docs.anthropic.com/en/docs/about-claude/models
159
+ * gemini: ai.google.dev/gemini-api/docs/models
160
+ * mistral: docs.mistral.ai/getting-started/models
161
+ * together: api.together.ai/models
162
+ * cerebras: cloud.cerebras.ai/docs
163
+ * openai: platform.openai.com/docs/models
164
+ * ollama: ollama.com/library
165
+ */
166
+ const LLM_DEFAULTS = {
167
+ // ── Free / fast ────────────────────────────────────────────────────────
168
+ groq: {
169
+ baseUrl: 'https://api.groq.com/openai/v1',
170
+ model: 'llama-3.3-70b-versatile', // fastest free tier, great for scripts
171
+ apiKeyEnv: 'GROQ_API_KEY',
172
+ },
173
+ cerebras: {
174
+ baseUrl: 'https://api.cerebras.ai/v1',
175
+ model: 'llama3.3-70b', // 2000+ tok/s — fastest inference today
176
+ apiKeyEnv: 'CEREBRAS_API_KEY',
177
+ },
178
+ // ── Router (access all models with one key) ────────────────────────────
179
+ openrouter: {
180
+ baseUrl: 'https://openrouter.ai/api/v1',
181
+ model: 'anthropic/claude-sonnet-4-5', // override with LLM_MODEL for any model
182
+ apiKeyEnv: 'OPENROUTER_API_KEY',
183
+ },
184
+ // ── Direct cloud APIs ──────────────────────────────────────────────────
185
+ anthropic: {
186
+ // Anthropic uses /v1/messages (not /chat/completions) — handled in chat()
187
+ baseUrl: 'https://api.anthropic.com/v1',
188
+ model: 'claude-sonnet-4-5',
189
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
190
+ },
191
+ gemini: {
192
+ // Google uses openai-compat endpoint via generativelanguage
193
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
194
+ model: 'gemini-2.0-flash',
195
+ apiKeyEnv: 'GEMINI_API_KEY',
196
+ },
197
+ mistral: {
198
+ baseUrl: 'https://api.mistral.ai/v1',
199
+ model: 'mistral-large-latest',
200
+ apiKeyEnv: 'MISTRAL_API_KEY',
201
+ },
202
+ together: {
203
+ baseUrl: 'https://api.together.xyz/v1',
204
+ model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
205
+ apiKeyEnv: 'TOGETHER_API_KEY',
206
+ },
207
+ openai: {
208
+ baseUrl: 'https://api.openai.com/v1',
209
+ model: 'gpt-4o',
210
+ apiKeyEnv: 'OPENAI_API_KEY',
211
+ },
212
+ // ── Local / self-hosted ────────────────────────────────────────────────
213
+ ollama: {
214
+ baseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434/api',
215
+ model: process.env.OLLAMA_MODEL || 'llama3.3',
216
+ apiKeyEnv: null,
217
+ },
218
+ // ── Generic OpenAI-compatible webhook ─────────────────────────────────
219
+ // Covers: LM Studio, vLLM, LocalAI, VoxForge, any custom proxy
220
+ webhook: {
221
+ baseUrl: process.env.WEBHOOK_LLM_URL || '',
222
+ model: process.env.LLM_MODEL || 'default',
223
+ apiKeyEnv: null, // auth via WEBHOOK_LLM_HEADERS
224
+ },
225
+ };
226
+
227
+ class LLMProvider {
228
+ constructor() {
229
+ const name = (process.env.PROVIDER_LLM || 'groq').toLowerCase();
230
+ const defaults = LLM_DEFAULTS[name] || LLM_DEFAULTS.groq;
231
+ this.name = name;
232
+ this.baseUrl = process.env.LLM_BASE_URL || defaults.baseUrl;
233
+ this.model = process.env.LLM_MODEL || defaults.model;
234
+ this.apiKey = defaults.apiKeyEnv ? process.env[defaults.apiKeyEnv] : null;
235
+ this._requiredEnv = defaults.apiKeyEnv || null;
236
+ // Do NOT throw here — defer to chat() so the MCP server can start without all keys
237
+ }
238
+
239
+ /**
240
+ * Chat completion — OpenAI-compatible interface (with Anthropic + Gemini shims).
241
+ * @param {{ system: string, user: string, temperature?: number, maxTokens?: number }} opts
242
+ * @returns {Promise<string>}
243
+ */
244
+ async chat({ system, user, temperature = 1, maxTokens = 8000 }) {
245
+ if (this._requiredEnv && !this.apiKey) {
246
+ throw new Error(`Missing env var ${this._requiredEnv} for LLM provider ${this.name}. Run: npm run setup`);
247
+ }
248
+
249
+ // ── Anthropic native API (uses /messages, different schema) ──────────
250
+ if (this.name === 'anthropic') {
251
+ return this._anthropicChat({ system, user, temperature, maxTokens });
252
+ }
253
+
254
+ const body = {
255
+ model: this.model,
256
+ messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
257
+ temperature,
258
+ max_tokens: maxTokens,
259
+ };
260
+
261
+ const authHeader = this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
262
+
263
+ // OpenRouter requires an extra header for attribution (optional but encouraged)
264
+ if (this.name === 'openrouter') {
265
+ authHeader['HTTP-Referer'] = 'https://github.com/AMG555/FrameForge';
266
+ authHeader['X-Title'] = 'FrameForge';
267
+ }
268
+
269
+ // Ollama uses /api/chat (not /chat/completions)
270
+ const url = this.name === 'ollama'
271
+ ? `${this.baseUrl}/chat`
272
+ : `${this.baseUrl}/chat/completions`;
273
+
274
+ return withRetry(async () => {
275
+ const resp = await httpPost(url, body, authHeader);
276
+ const content = resp?.choices?.[0]?.message?.content
277
+ ?? resp?.message?.content
278
+ ?? resp?.content;
279
+ if (!content) throw new Error(`LLM returned no content. finish_reason=${resp?.choices?.[0]?.finish_reason}`);
280
+ return content;
281
+ }, { maxAttempts: 3, baseDelayMs: 10000, label: `LLM(${this.name}/${this.model})` });
282
+ }
283
+
284
+ /**
285
+ * Anthropic /v1/messages shim — maps to the same output as OpenAI-compat.
286
+ * @private
287
+ */
288
+ async _anthropicChat({ system, user, temperature, maxTokens }) {
289
+ const body = {
290
+ model: this.model,
291
+ max_tokens: maxTokens,
292
+ system,
293
+ messages: [{ role: 'user', content: user }],
294
+ };
295
+ const headers = {
296
+ 'x-api-key': this.apiKey,
297
+ 'anthropic-version': '2023-06-01',
298
+ };
299
+ return withRetry(async () => {
300
+ const resp = await httpPost(`${this.baseUrl}/messages`, body, headers);
301
+ const content = resp?.content?.[0]?.text;
302
+ if (!content) throw new Error(`Anthropic returned no content: ${JSON.stringify(resp).slice(0, 200)}`);
303
+ return content;
304
+ }, { maxAttempts: 3, baseDelayMs: 10000, label: `LLM(anthropic/${this.model})` });
305
+ }
306
+
307
+ /** Parse JSON out of the raw LLM response, stripping markdown fences. */
308
+ static parseJson(raw) {
309
+ let s = raw.replace(/```json/gi, '').replace(/```/g, '').trim();
310
+ const fi = s.indexOf('{'), la = s.lastIndexOf('}');
311
+ if (fi < 0 || la <= fi) throw new Error('No JSON object in LLM response');
312
+ s = s.slice(fi, la + 1).replace(/,\s*([}\]])/g, '$1').replace(/\*\*/g, '');
313
+ return JSON.parse(s);
314
+ }
315
+ }
316
+
317
+ // ─────────────────────────────────────────────────────────────────────────────
318
+ // TTS PROVIDER
319
+ // ─────────────────────────────────────────────────────────────────────────────
320
+
321
+ // ── Task #17: Natural narrator voice table ────────────────────────────────
322
+ // Voices are grouped by register. Override with TTS_VOICE env var.
323
+ // No artificial rate deepening — all voices are selected for natural quality.
324
+ const NARRATOR_VOICES = {
325
+ // edge-tts
326
+ 'edge-tts': {
327
+ male: process.env.TTS_VOICE_MALE || 'en-US-BrianNeural', // deep, authoritative
328
+ female: process.env.TTS_VOICE_FEMALE || 'en-US-JennyNeural', // warm, clear
329
+ default: process.env.TTS_VOICE || 'en-US-BrianNeural',
330
+ },
331
+ // ElevenLabs — voice IDs
332
+ elevenlabs: {
333
+ male: process.env.ELEVENLABS_VOICE_MALE || 'pNInz6obpgDQGcFmaJgB', // Adam
334
+ female: process.env.ELEVENLABS_VOICE_FEMALE || '21m00Tcm4TlvDq8ikWAM', // Rachel
335
+ default: process.env.ELEVENLABS_VOICE_ID || 'pNInz6obpgDQGcFmaJgB',
336
+ },
337
+ // Azure cognitive services
338
+ azure: {
339
+ male: process.env.AZURE_VOICE_MALE || 'en-US-GuyNeural',
340
+ female: process.env.AZURE_VOICE_FEMALE || 'en-US-JennyNeural',
341
+ default: process.env.TTS_VOICE || 'en-US-GuyNeural',
342
+ },
343
+ // OpenAI TTS
344
+ openai: {
345
+ male: 'onyx',
346
+ female: 'nova',
347
+ default: process.env.TTS_VOICE || 'onyx',
348
+ },
349
+ };
350
+
351
+ class TTSProvider {
352
+ /**
353
+ * @param {object} [opts] Per-call overrides — avoids mutating process.env in concurrent calls.
354
+ * @param {string} [opts.platform] TTS provider name (overrides PROVIDER_TTS)
355
+ * @param {string} [opts.voice] Voice ID (overrides TTS_VOICE)
356
+ * @param {string} [opts.rate] Speaking rate (overrides TTS_RATE)
357
+ * @param {string} [opts.apiKey] API key (overrides provider-specific env var)
358
+ * @param {string} [opts.webhookUrl] Webhook URL (overrides WEBHOOK_TTS_URL)
359
+ */
360
+ constructor(opts = {}) {
361
+ this.name = (opts.platform || process.env.PROVIDER_TTS || 'edge-tts').toLowerCase();
362
+ this._overrides = opts; // stash for _* methods that read env vars
363
+ }
364
+
365
+ /**
366
+ * Select the appropriate narrator voice for this provider.
367
+ * Respects TTS_VOICE env var as an explicit override; otherwise
368
+ * picks by gender preference (TTS_VOICE_GENDER=male|female|default).
369
+ *
370
+ * @param {string} [explicitVoice] voice passed by caller (takes priority)
371
+ * @returns {string}
372
+ */
373
+ selectNarratorVoice(explicitVoice) {
374
+ if (explicitVoice) return explicitVoice;
375
+ const voices = NARRATOR_VOICES[this.name] || NARRATOR_VOICES['edge-tts'];
376
+ const gender = (process.env.TTS_VOICE_GENDER || 'default').toLowerCase();
377
+ return voices[gender] || voices.default;
378
+ }
379
+
380
+ /**
381
+ * Synthesize text to an MP3/M4A file.
382
+ * @param {{ text: string, voice?: string, outputPath: string }} opts
383
+ * voice is optional — if omitted, selectNarratorVoice() picks automatically
384
+ * @returns {Promise<string>} resolves with outputPath
385
+ */
386
+ async synthesize({ text, voice, outputPath }) {
387
+ const resolvedVoice = this.selectNarratorVoice(voice);
388
+ switch (this.name) {
389
+ case 'edge-tts': return this._edgeTTS({ text, voice: resolvedVoice, outputPath });
390
+ case 'elevenlabs': return this._elevenLabs({ text, voice: resolvedVoice, outputPath });
391
+ case 'azure': return this._azureTTS({ text, voice: resolvedVoice, outputPath });
392
+ case 'openai': return this._openaiTTS({ text, voice: resolvedVoice, outputPath });
393
+ case 'custom':
394
+ case 'webhook': return this._customTTS({ text, voice: resolvedVoice, outputPath });
395
+ default: return this._edgeTTS({ text, voice: resolvedVoice, outputPath });
396
+ }
397
+ }
398
+
399
+ async _customTTS({ text, voice, outputPath }) {
400
+ const url = this._overrides.webhookUrl || process.env.CUSTOM_TTS_URL || process.env.TTS_WEBHOOK_URL;
401
+ if (!url) throw new Error('CUSTOM_TTS_URL or TTS_WEBHOOK_URL not set for custom/webhook TTS provider');
402
+ const apiKey = this._overrides.apiKey || process.env.CUSTOM_TTS_KEY || '';
403
+ const headers = { 'Content-Type': 'application/json' };
404
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
405
+
406
+ const isSpeechEndpoint = /\/v1\/audio\/speech/i.test(url);
407
+ const rate = this._overrides.rate || process.env.TTS_RATE || '+0%';
408
+ const body = isSpeechEndpoint
409
+ ? { model: process.env.TTS_MODEL || 'tts-1', input: text, voice: voice || 'alloy' }
410
+ : { text, voice, rate };
411
+
412
+ return withRetry(async () => {
413
+ const audio = await httpPost(url, body, headers);
414
+ await fs.writeFile(outputPath, Buffer.isBuffer(audio) ? audio : Buffer.from(audio, 'binary'));
415
+ return outputPath;
416
+ }, { maxAttempts: 3, baseDelayMs: 3000, label: 'TTS(custom)' });
417
+ }
418
+
419
+ async _edgeTTS({ text, voice, outputPath }) {
420
+ const rawPath = outputPath.replace(/\.[^.]+$/, '_raw.mp3');
421
+ const safeVoice = voice || NARRATOR_VOICES['edge-tts'].default;
422
+ // No artificial rate manipulation — use the voice's natural cadence.
423
+ // Only a mild rate adjustment from env (e.g. TTS_RATE='+0%') is supported.
424
+ const rate = this._overrides.rate || process.env.TTS_RATE || '+0%';
425
+ return withRetry(async () => {
426
+ const _shell = process.platform === 'win32';
427
+ await new Promise((resolve, reject) => {
428
+ const proc = spawn('edge-tts', ['--voice', safeVoice, '--rate', rate, '--text', text, '--write-media', rawPath], { timeout: 40000, shell: _shell });
429
+ proc.on('close', code => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}`)));
430
+ proc.on('error', reject);
431
+ });
432
+ // Normalise audio via FFmpeg — trim trailing silence, normalise levels
433
+ await new Promise((resolve, reject) => {
434
+ const proc = spawn('ffmpeg', ['-nostdin', '-y', '-threads', '2',
435
+ '-i', rawPath,
436
+ '-af', 'silenceremove=stop_periods=-1:stop_duration=0.3:stop_threshold=-40dB,highpass=f=80,volume=1.0,dynaudnorm=f=150:g=5:p=0.95',
437
+ '-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k', outputPath], { timeout: 30000, shell: _shell });
438
+ proc.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)));
439
+ proc.on('error', reject);
440
+ });
441
+ await fs.unlink(rawPath).catch(() => {});
442
+ return outputPath;
443
+ }, { maxAttempts: 3, baseDelayMs: 3000, label: 'TTS(edge-tts)' });
444
+ }
445
+
446
+ async _elevenLabs({ text, voice, outputPath }) {
447
+ const apiKey = this._overrides.apiKey || process.env.ELEVENLABS_API_KEY;
448
+ if (!apiKey) throw new Error('ELEVENLABS_API_KEY not set');
449
+ const voiceId = process.env.ELEVENLABS_VOICE_ID || voice || 'Rachel';
450
+ const body = { text, model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.5, similarity_boost: 0.75 } };
451
+ return withRetry(async () => {
452
+ const audio = await httpPost(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, body, {
453
+ 'xi-api-key': apiKey, 'Accept': 'audio/mpeg', 'Content-Type': 'application/json',
454
+ });
455
+ await fs.writeFile(outputPath, Buffer.isBuffer(audio) ? audio : Buffer.from(audio, 'binary'));
456
+ return outputPath;
457
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'TTS(elevenlabs)' });
458
+ }
459
+
460
+ async _azureTTS({ text, voice, outputPath }) {
461
+ // Accept both AZURE_SPEECH_KEY (documented) and AZURE_TTS_KEY (legacy)
462
+ const key = this._overrides.apiKey || process.env.AZURE_SPEECH_KEY || process.env.AZURE_TTS_KEY;
463
+ const region = process.env.AZURE_SPEECH_REGION || process.env.AZURE_TTS_REGION || 'eastus';
464
+ if (!key) throw new Error('AZURE_SPEECH_KEY env var not set');
465
+ const resolvedVoice = voice || NARRATOR_VOICES.azure.default;
466
+ // Natural rate via override or env TTS_RATE; default is no change (+0%)
467
+ const rate = this._overrides.rate || process.env.TTS_RATE || '+0%';
468
+ const ssml = `<speak version="1.0" xml:lang="en-US"><voice name="${resolvedVoice}"><prosody rate="${rate}">${text}</prosody></voice></speak>`;
469
+ return withRetry(async () => {
470
+ const audio = await httpPost(
471
+ `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`,
472
+ Buffer.from(ssml, 'utf8'),
473
+ { 'Ocp-Apim-Subscription-Key': key, 'Content-Type': 'application/ssml+xml', 'X-Microsoft-OutputFormat': 'audio-48khz-192kbitrate-mono-mp3' }
474
+ );
475
+ await fs.writeFile(outputPath, Buffer.isBuffer(audio) ? audio : Buffer.from(audio, 'binary'));
476
+ return outputPath;
477
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'TTS(azure)' });
478
+ }
479
+
480
+ async _openaiTTS({ text, voice, outputPath }) {
481
+ const apiKey = this._overrides.apiKey || process.env.OPENAI_API_KEY;
482
+ if (!apiKey) throw new Error('OPENAI_API_KEY not set');
483
+ const body = { model: 'tts-1', input: text, voice: voice || 'onyx' };
484
+ return withRetry(async () => {
485
+ const audio = await httpPost('https://api.openai.com/v1/audio/speech', body, { Authorization: `Bearer ${apiKey}` });
486
+ await fs.writeFile(outputPath, Buffer.isBuffer(audio) ? audio : Buffer.from(audio, 'binary'));
487
+ return outputPath;
488
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'TTS(openai)' });
489
+ }
490
+ }
491
+
492
+ // ─────────────────────────────────────────────────────────────────────────────
493
+ // IMAGE GENERATION PROVIDER
494
+ // ─────────────────────────────────────────────────────────────────────────────
495
+
496
+ class ImageProvider {
497
+ constructor() {
498
+ this.name = (process.env.PROVIDER_IMAGE || 'pollinations').toLowerCase();
499
+ }
500
+
501
+ /**
502
+ * Generate an image from a prompt and save to outputPath.
503
+ * @param {{ prompt: string, negativePrompt?: string, outputPath: string, width?: number, height?: number, seed?: number }} opts
504
+ * @returns {Promise<string>} resolves with outputPath
505
+ */
506
+ async generate({ prompt, negativePrompt = '', outputPath, width = 1920, height = 1080, seed = null }) {
507
+ switch (this.name) {
508
+ case 'pollinations': return this._pollinations({ prompt, negativePrompt, outputPath, width, height, seed });
509
+ case 'falai': return this._falai({ prompt, negativePrompt, outputPath, width, height });
510
+ case 'replicate': return this._replicate({ prompt, negativePrompt, outputPath, width, height });
511
+ case 'dalle': return this._dalle({ prompt, outputPath });
512
+ case 'custom':
513
+ case 'webhook': return this._customImage({ prompt, negativePrompt, outputPath, width, height });
514
+ default: return this._pollinations({ prompt, negativePrompt, outputPath, width, height, seed });
515
+ }
516
+ }
517
+
518
+ async _customImage({ prompt, negativePrompt, outputPath, width, height }) {
519
+ const url = process.env.CUSTOM_IMAGE_URL || process.env.IMAGE_WEBHOOK_URL;
520
+ if (!url) throw new Error('CUSTOM_IMAGE_URL or IMAGE_WEBHOOK_URL not set for custom/webhook image provider');
521
+ const apiKey = process.env.CUSTOM_IMAGE_KEY || '';
522
+ const headers = { 'Content-Type': 'application/json' };
523
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
524
+
525
+ const isDalleFormat = /\/v1\/images\/generations/i.test(url);
526
+ const body = isDalleFormat
527
+ ? { prompt, n: 1, size: `${width}x${height}` }
528
+ : { prompt, negative_prompt: negativePrompt, width, height };
529
+
530
+ return withRetry(async () => {
531
+ const resp = await httpPost(url, body, headers);
532
+ const imageUrl = resp?.data?.[0]?.url || resp?.images?.[0]?.url || resp?.url;
533
+ const b64Data = resp?.data?.[0]?.b64_json || resp?.b64_json;
534
+ if (b64Data) {
535
+ await fs.writeFile(outputPath, Buffer.from(b64Data, 'base64'));
536
+ return outputPath;
537
+ }
538
+ if (imageUrl) {
539
+ await this._download(imageUrl, outputPath, 60);
540
+ return outputPath;
541
+ }
542
+ if (Buffer.isBuffer(resp)) {
543
+ await fs.writeFile(outputPath, resp);
544
+ return outputPath;
545
+ }
546
+ throw new Error(`Custom image endpoint returned unrecognized payload format: ${JSON.stringify(resp).slice(0, 100)}`);
547
+ }, { maxAttempts: 3, baseDelayMs: 3000, label: 'Image(custom)' });
548
+ }
549
+
550
+ async _pollinations({ prompt, negativePrompt, outputPath, width, height, seed }) {
551
+ const q = encodeURIComponent(prompt);
552
+ const neg = negativePrompt ? `&negative=${encodeURIComponent(negativePrompt)}` : '';
553
+ const s = seed !== null ? `&seed=${seed}` : '';
554
+ const models = [['flux', 90], ['turbo', 45]];
555
+ return withRetry(async () => {
556
+ for (const [model, timeout] of models) {
557
+ const url = `https://image.pollinations.ai/prompt/${q}?width=${width}&height=${height}&model=${model}&nologo=true&nofeed=true${s}${neg}`;
558
+ try {
559
+ await this._download(url, outputPath, timeout);
560
+ const stat = await fs.stat(outputPath);
561
+ if (stat.size > 5000) return outputPath;
562
+ } catch (err) {
563
+ // try next model
564
+ }
565
+ }
566
+ throw new Error('All Pollinations models failed');
567
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'Image(pollinations)' });
568
+ }
569
+
570
+ async _falai({ prompt, negativePrompt, outputPath, width, height }) {
571
+ const apiKey = process.env.FALAI_API_KEY;
572
+ if (!apiKey) throw new Error('FALAI_API_KEY not set');
573
+ return withRetry(async () => {
574
+ const resp = await httpPost('https://fal.run/fal-ai/flux/schnell', {
575
+ prompt, negative_prompt: negativePrompt,
576
+ image_size: { width, height }, num_images: 1,
577
+ }, { Authorization: `Key ${apiKey}` });
578
+ const imageUrl = resp?.images?.[0]?.url;
579
+ if (!imageUrl) throw new Error('fal.ai returned no image URL');
580
+ await this._download(imageUrl, outputPath, 60);
581
+ return outputPath;
582
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'Image(falai)' });
583
+ }
584
+
585
+ async _replicate({ prompt, negativePrompt, outputPath, width, height }) {
586
+ const apiKey = process.env.REPLICATE_API_KEY;
587
+ if (!apiKey) throw new Error('REPLICATE_API_KEY not set');
588
+ return withRetry(async () => {
589
+ // Start prediction
590
+ const pred = await httpPost('https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions', {
591
+ input: { prompt, negative_prompt: negativePrompt, width, height, output_format: 'png' },
592
+ }, { Authorization: `Token ${apiKey}` });
593
+ // Poll until complete (max 60s)
594
+ const pollUrl = pred.urls?.get;
595
+ if (!pollUrl) throw new Error('Replicate: no polling URL');
596
+ for (let i = 0; i < 20; i++) {
597
+ await new Promise(r => setTimeout(r, 3000));
598
+ // Replicate polling is a GET request, not POST
599
+ const status = await httpGet(pollUrl, { Authorization: `Token ${apiKey}` });
600
+ if (status.status === 'succeeded') {
601
+ const imageUrl = Array.isArray(status.output) ? status.output[0] : status.output;
602
+ await this._download(imageUrl, outputPath, 60);
603
+ return outputPath;
604
+ }
605
+ if (status.status === 'failed') throw new Error(`Replicate failed: ${status.error}`);
606
+ }
607
+ throw new Error('Replicate: prediction timed out');
608
+ }, { maxAttempts: 2, baseDelayMs: 5000, label: 'Image(replicate)' });
609
+ }
610
+
611
+ async _dalle({ prompt, outputPath }) {
612
+ const apiKey = process.env.OPENAI_API_KEY;
613
+ if (!apiKey) throw new Error('OPENAI_API_KEY not set');
614
+ return withRetry(async () => {
615
+ const resp = await httpPost('https://api.openai.com/v1/images/generations', {
616
+ model: 'dall-e-3', prompt, n: 1, size: '1792x1024', response_format: 'url',
617
+ }, { Authorization: `Bearer ${apiKey}` });
618
+ const imageUrl = resp?.data?.[0]?.url;
619
+ if (!imageUrl) throw new Error('DALL-E returned no URL');
620
+ await this._download(imageUrl, outputPath, 60);
621
+ return outputPath;
622
+ }, { maxAttempts: 2, baseDelayMs: 5000, label: 'Image(dalle)' });
623
+ }
624
+
625
+ _download(url, outputPath, timeoutSec = 60, maxRedirects = 5) {
626
+ return new Promise((resolve, reject) => {
627
+ const fsSync = require('fs');
628
+ const lib = url.startsWith('https') ? https : http;
629
+ const timer = setTimeout(() => {
630
+ fsSync.unlink(outputPath, () => {});
631
+ reject(new Error(`Download timeout: ${url}`));
632
+ }, timeoutSec * 1000);
633
+
634
+ lib.get(url, res => {
635
+ // Follow redirects (Pollinations and image CDNs commonly use 302)
636
+ if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308)
637
+ && res.headers.location && maxRedirects > 0) {
638
+ res.resume();
639
+ clearTimeout(timer);
640
+ this._download(res.headers.location, outputPath, timeoutSec, maxRedirects - 1)
641
+ .then(resolve).catch(reject);
642
+ return;
643
+ }
644
+
645
+ if (res.statusCode < 200 || res.statusCode >= 300) {
646
+ clearTimeout(timer);
647
+ res.resume();
648
+ reject(new Error(`Download HTTP ${res.statusCode}: ${url}`));
649
+ return;
650
+ }
651
+
652
+ const file = fsSync.createWriteStream(outputPath);
653
+ res.pipe(file);
654
+ file.on('finish', () => { clearTimeout(timer); file.close(); resolve(outputPath); });
655
+ file.on('error', err => {
656
+ clearTimeout(timer);
657
+ fsSync.unlink(outputPath, () => {});
658
+ reject(err);
659
+ });
660
+ res.on('error', err => {
661
+ clearTimeout(timer);
662
+ file.destroy();
663
+ fsSync.unlink(outputPath, () => {});
664
+ reject(err);
665
+ });
666
+ }).on('error', err => { clearTimeout(timer); reject(err); });
667
+ });
668
+ }
669
+ }
670
+
671
+ // ─────────────────────────────────────────────────────────────────────────────
672
+ // GENERIC WEBHOOK / EXTERNAL API ADAPTER
673
+ // ─────────────────────────────────────────────────────────────────────────────
674
+ //
675
+ // Allows users to plug in ANY external HTTP endpoint for LLM, TTS, or Image
676
+ // without modifying core code. Configure entirely through environment variables.
677
+ //
678
+ // LLM webhook:
679
+ // PROVIDER_LLM=webhook
680
+ // WEBHOOK_LLM_URL=https://your-api.example.com/v1/chat
681
+ // WEBHOOK_LLM_METHOD=POST (default: POST)
682
+ // WEBHOOK_LLM_HEADERS={"Authorization":"Bearer TOKEN","X-Custom":"value"}
683
+ // WEBHOOK_LLM_BODY_TEMPLATE={"messages":[{"role":"user","content":"{{user}}"}],"system":"{{system}}"}
684
+ // Template vars: {{system}}, {{user}}, {{temperature}}, {{maxTokens}}
685
+ // WEBHOOK_LLM_RESPONSE_PATH=choices.0.message.content
686
+ // Dot-path into JSON response to extract the text content
687
+ //
688
+ // TTS webhook:
689
+ // PROVIDER_TTS=webhook
690
+ // WEBHOOK_TTS_URL=https://your-tts-api.example.com/synthesize
691
+ // WEBHOOK_TTS_METHOD=POST
692
+ // WEBHOOK_TTS_HEADERS={"Authorization":"Bearer TOKEN"}
693
+ // WEBHOOK_TTS_BODY_TEMPLATE={"text":"{{text}}","voice":"{{voice}}","format":"mp3"}
694
+ // WEBHOOK_TTS_RESPONSE=binary (binary = save raw bytes; json_url = follow URL in response body)
695
+ // WEBHOOK_TTS_RESPONSE_PATH=audio_url (only when WEBHOOK_TTS_RESPONSE=json_url)
696
+ //
697
+ // Image webhook:
698
+ // PROVIDER_IMAGE=webhook
699
+ // WEBHOOK_IMAGE_URL=https://your-image-api.example.com/generate
700
+ // WEBHOOK_IMAGE_METHOD=POST
701
+ // WEBHOOK_IMAGE_HEADERS={"Authorization":"Bearer TOKEN"}
702
+ // WEBHOOK_IMAGE_BODY_TEMPLATE={"prompt":"{{prompt}}","width":{{width}},"height":{{height}}}
703
+ // WEBHOOK_IMAGE_RESPONSE=binary | json_url
704
+ // WEBHOOK_IMAGE_RESPONSE_PATH=url
705
+
706
+ /** Resolve a dot-path like "choices.0.message.content" against an object */
707
+ function _resolvePath(obj, dotPath) {
708
+ if (!dotPath) return obj;
709
+ return dotPath.split('.').reduce((cur, key) => (cur != null ? cur[key] : undefined), obj);
710
+ }
711
+
712
+ /** Fill a template string with values, e.g. {{user}} → actual user text */
713
+ function _fillTemplate(template, vars) {
714
+ return Object.entries(vars).reduce((s, [k, v]) => {
715
+ // Handles both quoted "{{key}}" and bare {{key}} (for numbers/booleans)
716
+ return s.replace(new RegExp(`"{{${k}}}"`, 'g'), JSON.stringify(v))
717
+ .replace(new RegExp(`{{${k}}}`, 'g'), String(v));
718
+ }, template);
719
+ }
720
+
721
+ class WebhookLLMProvider {
722
+ constructor() {
723
+ this.name = 'webhook';
724
+ this.url = process.env.WEBHOOK_LLM_URL;
725
+ if (!this.url) throw new Error('WEBHOOK_LLM_URL must be set when PROVIDER_LLM=webhook');
726
+ }
727
+
728
+ async chat({ system = '', user = '', temperature = 0.7, maxTokens = 4000 }) {
729
+ const method = process.env.WEBHOOK_LLM_METHOD || 'POST';
730
+ const headers = JSON.parse(process.env.WEBHOOK_LLM_HEADERS || '{}');
731
+ const template = process.env.WEBHOOK_LLM_BODY_TEMPLATE
732
+ || '{"messages":[{"role":"system","content":"{{system}}"},{"role":"user","content":"{{user}}"}],"temperature":{{temperature}},"max_tokens":{{maxTokens}}}';
733
+ const bodyStr = _fillTemplate(template, { system, user, temperature, maxTokens });
734
+ const respPath = process.env.WEBHOOK_LLM_RESPONSE_PATH || 'choices.0.message.content';
735
+
736
+ return withRetry(async () => {
737
+ const resp = await httpPost(this.url, JSON.parse(bodyStr), headers);
738
+ const content = _resolvePath(resp, respPath);
739
+ if (!content) throw new Error(`Webhook LLM response has no content at path "${respPath}". Response: ${JSON.stringify(resp).slice(0, 200)}`);
740
+ return String(content);
741
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'LLM(webhook)' });
742
+ }
743
+
744
+ static parseJson(raw) { return LLMProvider.parseJson(raw); }
745
+ }
746
+
747
+ class WebhookTTSProvider {
748
+ constructor() {
749
+ this.name = 'webhook';
750
+ this.url = process.env.WEBHOOK_TTS_URL;
751
+ if (!this.url) throw new Error('WEBHOOK_TTS_URL must be set when PROVIDER_TTS=webhook');
752
+ }
753
+
754
+ async synthesize({ text, voice, outputPath }) {
755
+ const method = process.env.WEBHOOK_TTS_METHOD || 'POST';
756
+ const headers = JSON.parse(process.env.WEBHOOK_TTS_HEADERS || '{}');
757
+ const resolvedVoice = voice || process.env.TTS_VOICE || 'default';
758
+ const template = process.env.WEBHOOK_TTS_BODY_TEMPLATE
759
+ || '{"text":"{{text}}","voice":"{{voice}}"}';
760
+ const bodyStr = _fillTemplate(template, { text, voice: resolvedVoice });
761
+ const responseMode = process.env.WEBHOOK_TTS_RESPONSE || 'binary';
762
+
763
+ return withRetry(async () => {
764
+ if (responseMode === 'binary') {
765
+ const audio = await httpPost(this.url, JSON.parse(bodyStr), headers);
766
+ await fs.writeFile(outputPath, Buffer.isBuffer(audio) ? audio : Buffer.from(audio, 'binary'));
767
+ } else {
768
+ // json_url: response is JSON containing a URL to the audio file
769
+ const resp = await httpPost(this.url, JSON.parse(bodyStr), headers);
770
+ const respPath = process.env.WEBHOOK_TTS_RESPONSE_PATH || 'audio_url';
771
+ const audioUrl = _resolvePath(resp, respPath);
772
+ if (!audioUrl) throw new Error(`Webhook TTS: no audio URL at path "${respPath}"`);
773
+ const lib = audioUrl.startsWith('https') ? https : http;
774
+ await new Promise((resolve, reject) => {
775
+ const file = require('fs').createWriteStream(outputPath);
776
+ lib.get(audioUrl, res => {
777
+ res.pipe(file);
778
+ file.on('finish', () => { file.close(); resolve(); });
779
+ }).on('error', reject);
780
+ });
781
+ }
782
+ return outputPath;
783
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'TTS(webhook)' });
784
+ }
785
+ }
786
+
787
+ class WebhookImageProvider {
788
+ constructor() {
789
+ this.name = 'webhook';
790
+ this.url = process.env.WEBHOOK_IMAGE_URL;
791
+ if (!this.url) throw new Error('WEBHOOK_IMAGE_URL must be set when PROVIDER_IMAGE=webhook');
792
+ }
793
+
794
+ async generate({ prompt, negativePrompt = '', outputPath, width = 1920, height = 1080, seed = null }) {
795
+ const headers = JSON.parse(process.env.WEBHOOK_IMAGE_HEADERS || '{}');
796
+ const template = process.env.WEBHOOK_IMAGE_BODY_TEMPLATE
797
+ || '{"prompt":"{{prompt}}","negative_prompt":"{{negativePrompt}}","width":{{width}},"height":{{height}}}';
798
+ const bodyStr = _fillTemplate(template, { prompt, negativePrompt, width, height, seed: seed ?? 0 });
799
+ const responseMode = process.env.WEBHOOK_IMAGE_RESPONSE || 'json_url';
800
+
801
+ return withRetry(async () => {
802
+ if (responseMode === 'binary') {
803
+ const data = await httpPost(this.url, JSON.parse(bodyStr), headers);
804
+ await fs.writeFile(outputPath, Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary'));
805
+ } else {
806
+ const resp = await httpPost(this.url, JSON.parse(bodyStr), headers);
807
+ const respPath = process.env.WEBHOOK_IMAGE_RESPONSE_PATH || 'url';
808
+ const imageUrl = _resolvePath(resp, respPath);
809
+ if (!imageUrl) throw new Error(`Webhook Image: no URL at path "${respPath}"`);
810
+ const lib = imageUrl.startsWith('https') ? https : http;
811
+ await new Promise((resolve, reject) => {
812
+ const file = require('fs').createWriteStream(outputPath);
813
+ lib.get(imageUrl, res => {
814
+ res.pipe(file);
815
+ file.on('finish', () => { file.close(); resolve(); });
816
+ }).on('error', reject);
817
+ });
818
+ }
819
+ return outputPath;
820
+ }, { maxAttempts: 3, baseDelayMs: 5000, label: 'Image(webhook)' });
821
+ }
822
+ }
823
+
824
+ // ─────────────────────────────────────────────────────────────────────────────
825
+ // FACTORY — instantiate the right provider class based on env vars
826
+ // ─────────────────────────────────────────────────────────────────────────────
827
+
828
+ /** Returns the configured LLMProvider (or WebhookLLMProvider if PROVIDER_LLM=webhook) */
829
+ function createLLMProvider() {
830
+ const name = (process.env.PROVIDER_LLM || 'groq').toLowerCase();
831
+ if (name === 'webhook') return new WebhookLLMProvider();
832
+ return new LLMProvider();
833
+ }
834
+
835
+ /** Returns the configured TTSProvider (or WebhookTTSProvider if PROVIDER_TTS=webhook) */
836
+ function createTTSProvider() {
837
+ const name = (process.env.PROVIDER_TTS || 'edge-tts').toLowerCase();
838
+ if (name === 'webhook') return new WebhookTTSProvider();
839
+ return new TTSProvider();
840
+ }
841
+
842
+ /** Returns the configured ImageProvider (or WebhookImageProvider if PROVIDER_IMAGE=webhook) */
843
+ function createImageProvider() {
844
+ const name = (process.env.PROVIDER_IMAGE || 'pollinations').toLowerCase();
845
+ if (name === 'webhook') return new WebhookImageProvider();
846
+ return new ImageProvider();
847
+ }
848
+
849
+ module.exports = {
850
+ LLMProvider, TTSProvider, ImageProvider, withRetry,
851
+ WebhookLLMProvider, WebhookTTSProvider, WebhookImageProvider,
852
+ createLLMProvider, createTTSProvider, createImageProvider,
853
+ };