muxmind-ai 2.1.1

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.
package/src/config.js ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * MuxMind AI — System Configuration
3
+ * Provider catalog, model fallback chains, and system-wide constants.
4
+ * NOTE: No API keys live here. Keys are supplied per-request by the client
5
+ * from its own local vault (localStorage) or from a local .env the user
6
+ * controls on their own machine. This file is safe to publish publicly.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const PROVIDERS = {
12
+ openai: {
13
+ id: 'openai',
14
+ label: 'OpenAI',
15
+ color: '#10a37f',
16
+ baseUrl: 'https://api.openai.com/v1',
17
+ modelsEndpoint: '/models',
18
+ chatEndpoint: '/chat/completions',
19
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
20
+ defaultModels: ['gpt-4o-mini', 'gpt-4o', 'gpt-4.1-mini', 'gpt-4.1', 'o4-mini'],
21
+ keyConsoleUrl: 'https://platform.openai.com/api-keys',
22
+ docsUrl: 'https://platform.openai.com/docs/api-reference',
23
+ keyPrefix: 'sk-',
24
+ },
25
+ anthropic: {
26
+ id: 'anthropic',
27
+ label: 'Anthropic',
28
+ color: '#d97757',
29
+ baseUrl: 'https://api.anthropic.com/v1',
30
+ modelsEndpoint: '/models',
31
+ chatEndpoint: '/messages',
32
+ authHeader: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
33
+ defaultModels: ['claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-1', 'claude-3-5-haiku-20241022'],
34
+ keyConsoleUrl: 'https://console.anthropic.com/settings/keys',
35
+ docsUrl: 'https://docs.claude.com',
36
+ keyPrefix: 'sk-ant-',
37
+ },
38
+ groq: {
39
+ id: 'groq',
40
+ label: 'Groq',
41
+ color: '#f55036',
42
+ baseUrl: 'https://api.groq.com/openai/v1',
43
+ modelsEndpoint: '/models',
44
+ chatEndpoint: '/chat/completions',
45
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
46
+ defaultModels: ['llama-3.1-8b-instant', 'llama-3.3-70b-versatile'],
47
+ keyConsoleUrl: 'https://console.groq.com/keys',
48
+ docsUrl: 'https://console.groq.com/docs',
49
+ keyPrefix: 'gsk_',
50
+ },
51
+ gemini: {
52
+ id: 'gemini',
53
+ label: 'Google Gemini',
54
+ color: '#4285f4',
55
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
56
+ modelsEndpoint: '/models',
57
+ chatEndpoint: '/models/{model}:generateContent',
58
+ authHeader: () => ({}),
59
+ authQuery: (key) => `key=${key}`,
60
+ defaultModels: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-flash', 'gemini-1.5-pro'],
61
+ keyConsoleUrl: 'https://aistudio.google.com/app/apikey',
62
+ docsUrl: 'https://ai.google.dev/gemini-api/docs',
63
+ keyPrefix: 'AIza',
64
+ },
65
+ openrouter: {
66
+ id: 'openrouter',
67
+ label: 'OpenRouter',
68
+ color: '#8b5cf6',
69
+ baseUrl: 'https://openrouter.ai/api/v1',
70
+ modelsEndpoint: '/models',
71
+ chatEndpoint: '/chat/completions',
72
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
73
+ defaultModels: ['meta-llama/llama-3.1-8b-instruct:free'],
74
+ keyConsoleUrl: 'https://openrouter.ai/settings/keys',
75
+ docsUrl: 'https://openrouter.ai/docs',
76
+ keyPrefix: 'sk-or-',
77
+ },
78
+ mistral: {
79
+ id: 'mistral',
80
+ label: 'Mistral AI',
81
+ color: '#fa520f',
82
+ baseUrl: 'https://api.mistral.ai/v1',
83
+ modelsEndpoint: '/models',
84
+ chatEndpoint: '/chat/completions',
85
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
86
+ defaultModels: ['mistral-small-latest', 'mistral-large-latest'],
87
+ keyConsoleUrl: 'https://console.mistral.ai/api-keys',
88
+ docsUrl: 'https://docs.mistral.ai',
89
+ keyPrefix: '',
90
+ },
91
+ deepseek: {
92
+ id: 'deepseek',
93
+ label: 'DeepSeek',
94
+ color: '#4d6bfe',
95
+ baseUrl: 'https://api.deepseek.com/v1',
96
+ modelsEndpoint: '/models',
97
+ chatEndpoint: '/chat/completions',
98
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
99
+ defaultModels: ['deepseek-chat', 'deepseek-reasoner'],
100
+ keyConsoleUrl: 'https://platform.deepseek.com/api_keys',
101
+ docsUrl: 'https://api-docs.deepseek.com',
102
+ keyPrefix: 'sk-',
103
+ },
104
+ xai: {
105
+ id: 'xai',
106
+ label: 'xAI (Grok)',
107
+ color: '#1a1a1a',
108
+ baseUrl: 'https://api.x.ai/v1',
109
+ modelsEndpoint: '/models',
110
+ chatEndpoint: '/chat/completions',
111
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
112
+ defaultModels: ['grok-3-mini', 'grok-3'],
113
+ keyConsoleUrl: 'https://console.x.ai',
114
+ docsUrl: 'https://docs.x.ai',
115
+ keyPrefix: 'xai-',
116
+ },
117
+ cohere: {
118
+ id: 'cohere',
119
+ label: 'Cohere',
120
+ color: '#39594d',
121
+ baseUrl: 'https://api.cohere.com/v1',
122
+ modelsEndpoint: '/models',
123
+ chatEndpoint: '/chat',
124
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
125
+ defaultModels: ['command-r', 'command-r-plus'],
126
+ keyConsoleUrl: 'https://dashboard.cohere.com/api-keys',
127
+ docsUrl: 'https://docs.cohere.com',
128
+ keyPrefix: '',
129
+ },
130
+ together: {
131
+ id: 'together',
132
+ label: 'Together AI',
133
+ color: '#0f6fff',
134
+ baseUrl: 'https://api.together.xyz/v1',
135
+ modelsEndpoint: '/models',
136
+ chatEndpoint: '/chat/completions',
137
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
138
+ defaultModels: ['meta-llama/Llama-3.3-70B-Instruct-Turbo-Free', 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo'],
139
+ keyConsoleUrl: 'https://api.together.xyz/settings/api-keys',
140
+ docsUrl: 'https://docs.together.ai',
141
+ keyPrefix: '',
142
+ },
143
+ cerebras: {
144
+ id: 'cerebras',
145
+ label: 'Cerebras',
146
+ color: '#f6521f',
147
+ baseUrl: 'https://api.cerebras.ai/v1',
148
+ modelsEndpoint: '/models',
149
+ chatEndpoint: '/chat/completions',
150
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
151
+ defaultModels: ['llama3.1-8b', 'llama-3.3-70b'],
152
+ keyConsoleUrl: 'https://cloud.cerebras.ai',
153
+ docsUrl: 'https://inference-docs.cerebras.ai',
154
+ keyPrefix: 'csk-',
155
+ },
156
+ perplexity: {
157
+ id: 'perplexity',
158
+ label: 'Perplexity',
159
+ color: '#20808d',
160
+ baseUrl: 'https://api.perplexity.ai',
161
+ modelsEndpoint: '/models',
162
+ chatEndpoint: '/chat/completions',
163
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
164
+ defaultModels: ['sonar', 'sonar-pro'],
165
+ keyConsoleUrl: 'https://www.perplexity.ai/settings/api',
166
+ docsUrl: 'https://docs.perplexity.ai',
167
+ keyPrefix: 'pplx-',
168
+ },
169
+ };
170
+
171
+ // Priority order for the staggered round-robin token optimizer.
172
+ const FALLBACK_CHAIN = ['groq', 'cerebras', 'gemini', 'deepseek', 'mistral', 'together', 'xai', 'cohere', 'openai', 'anthropic', 'perplexity', 'openrouter'];
173
+
174
+ // Providers with a documented image-generation endpoint we can drive from
175
+ // the same vault key the user already validated for chat.
176
+ const IMAGE_PROVIDERS = {
177
+ openai: {
178
+ endpoint: '/images/generations',
179
+ defaultModel: 'gpt-image-1',
180
+ buildBody: (prompt, opts) => ({ model: 'gpt-image-1', prompt, size: opts.size || '1024x1024', n: 1 }),
181
+ extractImages: (data) => (data.data || []).map((d) => (d.b64_json ? `data:image/png;base64,${d.b64_json}` : d.url)),
182
+ },
183
+ gemini: {
184
+ endpoint: '/models/{model}:generateContent',
185
+ // Google renames/retires its image-capable Gemini models fairly often
186
+ // (the old 'gemini-2.0-flash-exp-image-generation' preview is gone).
187
+ // Rather than pin one name that can go stale, we try this list in
188
+ // order and fall through to the next on a 404/"not found" style error.
189
+ defaultModel: 'gemini-3.1-flash-image',
190
+ fallbackModels: [
191
+ 'gemini-3.1-flash-image',
192
+ 'gemini-2.5-flash-image',
193
+ 'gemini-2.5-flash-image-preview',
194
+ 'gemini-2.0-flash-exp-image-generation',
195
+ ],
196
+ buildBody: (prompt) => ({
197
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
198
+ generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
199
+ }),
200
+ extractImages: (data) => {
201
+ const parts = data.candidates?.[0]?.content?.parts || [];
202
+ return parts
203
+ .filter((p) => p.inlineData)
204
+ .map((p) => `data:${p.inlineData.mimeType || 'image/png'};base64,${p.inlineData.data}`);
205
+ },
206
+ },
207
+ };
208
+
209
+ const COMPRESSION_LEVELS = {
210
+ 0: { label: 'Off', maxTokensFactor: 1.0, chunkSize: 0 },
211
+ 25: { label: 'Light', maxTokensFactor: 0.75, chunkSize: 96 },
212
+ 50: { label: 'Balanced', maxTokensFactor: 0.5, chunkSize: 48 },
213
+ 75: { label: 'Aggressive', maxTokensFactor: 0.25, chunkSize: 20 },
214
+ 99: { label: 'Extreme (99%)', maxTokensFactor: 0.01, chunkSize: 6 },
215
+ };
216
+
217
+ const SERVER_CONFIG = {
218
+ PORT: process.env.PORT || 8080,
219
+ RATE_LIMIT_WINDOW_MS: 60 * 1000,
220
+ RATE_LIMIT_MAX: 120,
221
+ LOGIN_RATE_LIMIT_MAX: 8,
222
+ BODY_LIMIT: '25mb',
223
+ ALLOWED_UPLOAD_EXT: ['.js', '.html', '.css', '.py', '.pdf', '.txt', '.png', '.jpg', '.jpeg', '.json', '.md'],
224
+ MAX_UPLOAD_SIZE_BYTES: 15 * 1024 * 1024,
225
+ SESSION_TTL_MS: 12 * 60 * 60 * 1000,
226
+ // Global npm installs put this package under a system-owned
227
+ // node_modules dir (often not writable, and shared across all users).
228
+ // Persist the auth store per-OS-user instead: ~/.muxmind-ai/auth.json
229
+ // (or %USERPROFILE%\.muxmind-ai\auth.json on Windows). This also means
230
+ // your login password survives a `npm update -g`.
231
+ AUTH_STORE_PATH: (() => {
232
+ const path = require('path');
233
+ const os = require('os');
234
+ const fs = require('fs');
235
+ const dir = path.join(os.homedir(), '.muxmind-ai');
236
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
237
+ return path.join(dir, 'auth.json');
238
+ })(),
239
+ };
240
+
241
+ const TTS_CONFIG = {
242
+ languages: {
243
+ ar: { code: 'ar-SA', label: 'Arabic' },
244
+ en: { code: 'en-US', label: 'English' },
245
+ },
246
+ defaultRate: 1.0,
247
+ defaultPitch: 1.0,
248
+ };
249
+
250
+ // Model-name heuristics used by the smart router to estimate a model's
251
+ // relative "power" and "speed" tiers when the provider doesn't expose this
252
+ // directly. Used to pick a sensible model automatically per task.
253
+ const MODEL_HINTS = [
254
+ { pattern: /mini|flash|8b|small|instant|haiku/i, tier: 'fast', power: 1 },
255
+ { pattern: /70b|large|pro(?!filic)|sonnet/i, tier: 'balanced', power: 2 },
256
+ { pattern: /opus|405b|reasoner|o1|o3|ultra/i, tier: 'heavy', power: 3 },
257
+ ];
258
+
259
+ module.exports = {
260
+ PROVIDERS,
261
+ IMAGE_PROVIDERS,
262
+ FALLBACK_CHAIN,
263
+ COMPRESSION_LEVELS,
264
+ SERVER_CONFIG,
265
+ TTS_CONFIG,
266
+ MODEL_HINTS,
267
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * MuxMind AI — Multi-Modal Upload Handler
3
+ * Parses uploaded files (code, text, PDF, images) into a normalized
4
+ * shape the router can inject into the conversation context.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const { SERVER_CONFIG } = require('./config');
11
+
12
+ const TEXT_EXTENSIONS = new Set(['.js', '.html', '.css', '.py', '.txt', '.json', '.md']);
13
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
14
+ const PDF_EXTENSIONS = new Set(['.pdf']);
15
+
16
+ function validateUpload(filename, sizeBytes) {
17
+ const ext = path.extname(filename).toLowerCase();
18
+ if (!SERVER_CONFIG.ALLOWED_UPLOAD_EXT.includes(ext)) {
19
+ return { ok: false, error: `File type ${ext} is not allowed.` };
20
+ }
21
+ if (sizeBytes > SERVER_CONFIG.MAX_UPLOAD_SIZE_BYTES) {
22
+ return { ok: false, error: `File exceeds max size of ${SERVER_CONFIG.MAX_UPLOAD_SIZE_BYTES / (1024 * 1024)}MB.` };
23
+ }
24
+ return { ok: true, ext };
25
+ }
26
+
27
+ /**
28
+ * Normalize an uploaded file buffer into a context-ready payload.
29
+ * - text/code files: returned as plain text, fenced by filename
30
+ * - images: returned as base64 data URLs for vision-capable models
31
+ * - PDFs: returned as base64 for provider-native PDF ingestion (e.g. Anthropic)
32
+ */
33
+ function parseUpload({ filename, mimetype, buffer }) {
34
+ const validation = validateUpload(filename, buffer.length);
35
+ if (!validation.ok) {
36
+ return { ok: false, error: validation.error, filename };
37
+ }
38
+
39
+ const ext = validation.ext;
40
+
41
+ if (TEXT_EXTENSIONS.has(ext)) {
42
+ return {
43
+ ok: true,
44
+ type: 'text',
45
+ filename,
46
+ ext,
47
+ content: buffer.toString('utf-8'),
48
+ };
49
+ }
50
+
51
+ if (IMAGE_EXTENSIONS.has(ext)) {
52
+ return {
53
+ ok: true,
54
+ type: 'image',
55
+ filename,
56
+ ext,
57
+ mimeType: mimetype || guessMime(ext),
58
+ base64: buffer.toString('base64'),
59
+ };
60
+ }
61
+
62
+ if (PDF_EXTENSIONS.has(ext)) {
63
+ return {
64
+ ok: true,
65
+ type: 'pdf',
66
+ filename,
67
+ ext,
68
+ mimeType: 'application/pdf',
69
+ base64: buffer.toString('base64'),
70
+ };
71
+ }
72
+
73
+ return { ok: false, error: `Unsupported file type: ${ext}`, filename };
74
+ }
75
+
76
+ function guessMime(ext) {
77
+ const map = {
78
+ '.png': 'image/png',
79
+ '.jpg': 'image/jpeg',
80
+ '.jpeg': 'image/jpeg',
81
+ '.pdf': 'application/pdf',
82
+ '.js': 'text/javascript',
83
+ '.css': 'text/css',
84
+ '.html': 'text/html',
85
+ '.py': 'text/x-python',
86
+ '.txt': 'text/plain',
87
+ '.json': 'application/json',
88
+ '.md': 'text/markdown',
89
+ };
90
+ return map[ext] || 'application/octet-stream';
91
+ }
92
+
93
+ /**
94
+ * Convert a batch of parsed uploads into a single text block that can be
95
+ * appended to the user's prompt as context (for text/code files), plus a
96
+ * separate array of image/pdf attachments for multi-modal providers.
97
+ */
98
+ function buildContextFromUploads(parsedFiles) {
99
+ const textBlocks = [];
100
+ const attachments = [];
101
+
102
+ for (const file of parsedFiles) {
103
+ if (!file.ok) continue;
104
+ if (file.type === 'text') {
105
+ textBlocks.push(`--- FILE: ${file.filename} ---\n${file.content}\n--- END FILE ---`);
106
+ } else {
107
+ attachments.push(file);
108
+ }
109
+ }
110
+
111
+ return {
112
+ contextText: textBlocks.join('\n\n'),
113
+ attachments,
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ validateUpload,
119
+ parseUpload,
120
+ buildContextFromUploads,
121
+ guessMime,
122
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * MuxMind AI — Image Generation Engine
3
+ * Drives an image-generation request through whichever vault key the
4
+ * client marks capable (OpenAI or Gemini). Keys are used only for the
5
+ * duration of the single request, never persisted server-side, matching
6
+ * the same policy as the chat router and health-check.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { PROVIDERS, IMAGE_PROVIDERS } = require('./config');
12
+
13
+ // A model is "gone" (renamed/retired/never existed under that id) rather
14
+ // than just erroring on this particular request — safe to silently retry
15
+ // the next candidate in that case, but not for other failures (bad key,
16
+ // quota, safety block, etc.) which should surface to the user immediately.
17
+ function isModelUnavailable(httpStatus, errText) {
18
+ if (httpStatus === 404) return true;
19
+ const msg = (errText || '').toLowerCase();
20
+ return (
21
+ msg.includes('not found') ||
22
+ msg.includes('not supported') ||
23
+ msg.includes('is not available') ||
24
+ msg.includes('unknown model') ||
25
+ msg.includes('deprecated')
26
+ );
27
+ }
28
+
29
+ async function callOnce(provider, imageConf, apiKey, chosenModel, prompt, size) {
30
+ const body = imageConf.buildBody(prompt, { size });
31
+ let url = `${provider.baseUrl}${imageConf.endpoint.replace('{model}', chosenModel)}`;
32
+ const headers = { 'Content-Type': 'application/json', ...provider.authHeader(apiKey) };
33
+ if (provider.authQuery) url += `?${provider.authQuery(apiKey)}`;
34
+
35
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
36
+ if (!res.ok) {
37
+ const errText = await res.text().catch(() => res.statusText);
38
+ const err = new Error(`${provider.label} image error (HTTP ${res.status}): ${errText.slice(0, 300)}`);
39
+ err.httpStatus = res.status;
40
+ err.rawText = errText;
41
+ throw err;
42
+ }
43
+ const data = await res.json();
44
+ const images = imageConf.extractImages(data).filter(Boolean);
45
+ if (images.length === 0) throw new Error(`${provider.label} returned no image data.`);
46
+ return images;
47
+ }
48
+
49
+ async function generateImage({ providerId, apiKey, model, prompt, size }) {
50
+ const provider = PROVIDERS[providerId];
51
+ const imageConf = IMAGE_PROVIDERS[providerId];
52
+ if (!provider || !imageConf) throw new Error(`Provider ${providerId} does not support image generation.`);
53
+
54
+ // If the caller pinned a specific model, respect it — no fallback walk.
55
+ // Otherwise walk the known-good candidate list until one actually works.
56
+ const candidates = model ? [model] : (imageConf.fallbackModels && imageConf.fallbackModels.length
57
+ ? imageConf.fallbackModels
58
+ : [imageConf.defaultModel]);
59
+
60
+ let lastErr;
61
+ for (const candidateModel of candidates) {
62
+ try {
63
+ return await callOnce(provider, imageConf, apiKey, candidateModel, prompt, size);
64
+ } catch (err) {
65
+ lastErr = err;
66
+ if (!isModelUnavailable(err.httpStatus, err.rawText)) throw err; // real error — stop and surface it
67
+ // otherwise: this model id is gone, try the next candidate
68
+ }
69
+ }
70
+ throw lastErr || new Error(`${provider.label} has no working image model available right now.`);
71
+ }
72
+
73
+ module.exports = { generateImage };