levix-bot 2.2.0 → 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.
@@ -48,13 +48,40 @@ const SETTINGS = [
48
48
  },
49
49
 
50
50
  // --- AI ----------------------------------------------------------------
51
+ //
52
+ // One provider answers chats, chosen here and configured right below: the
53
+ // dashboard renders every one of these fields (the provider as a dropdown,
54
+ // keys as password fields), so a provider switch is a settings change, not a
55
+ // code change or an env file.
56
+ {
57
+ key: "ai_provider",
58
+ type: "string",
59
+ default: "gemini",
60
+ choices: ["gemini", "openai", "anthropic"],
61
+ choiceLabels: {
62
+ gemini: "Google Gemini",
63
+ openai: "OpenAI / compatible",
64
+ anthropic: "Anthropic Claude",
65
+ },
66
+ group: "ai",
67
+ label: "AI provider",
68
+ hint: "Which API answers chats. gemini = Google Gemini (the only one that reads media and has built-in Google Search). openai = any OpenAI-compatible chat-completions server. anthropic = Claude.",
69
+ },
51
70
  {
52
71
  key: "gemini_api_key",
53
72
  type: "secret",
54
73
  default: "",
55
74
  group: "ai",
56
75
  label: "Gemini API key",
57
- hint: "Free key from aistudio.google.com/apikey — without it the AI commands stay off.",
76
+ hint: "Free key from aistudio.google.com/apikey. Needed when the provider is gemini, and by !stt / !generate in every case.",
77
+ },
78
+ {
79
+ key: "gemini_base_url",
80
+ type: "string",
81
+ default: "https://generativelanguage.googleapis.com",
82
+ group: "ai",
83
+ label: "Google Gemini base URL",
84
+ hint: "Google Generative Language API root. Change it for a proxy or compatible gateway; do not append /v1beta. Applies live to chat, media uploads, !stt and !generate.",
58
85
  },
59
86
  {
60
87
  key: "gemini_model",
@@ -131,22 +158,54 @@ const SETTINGS = [
131
158
  default: true,
132
159
  group: "ai",
133
160
  label: "Gemini Google Search",
134
- hint: "Lets Gemini use Google's own search grounding when a question needs current information. Gemini only — the Groq fallback is unaffected.",
161
+ hint: "Lets Gemini use Google's own search grounding when a question needs current information. Gemini only — the openai and anthropic providers never see it.",
162
+ },
163
+ {
164
+ key: "openai_api_key",
165
+ type: "secret",
166
+ default: "",
167
+ group: "ai",
168
+ label: "OpenAI-compatible API key",
169
+ hint: "Used when the provider is openai. Works with OpenAI itself or any server that speaks the same format: OpenRouter, Together, LM Studio, ...",
135
170
  },
136
171
  {
137
- key: "groq_api_key",
172
+ key: "openai_base_url",
173
+ type: "string",
174
+ default: "https://api.openai.com/v1",
175
+ group: "ai",
176
+ label: "OpenAI-compatible base URL",
177
+ hint: "The /v1 root of a chat-completions server. Examples: https://openrouter.ai/api/v1, https://api.together.xyz/v1, http://127.0.0.1:11434/v1 (Ollama).",
178
+ },
179
+ {
180
+ key: "openai_model",
181
+ type: "string",
182
+ default: "gpt-4o-mini",
183
+ group: "ai",
184
+ label: "OpenAI-compatible model",
185
+ hint: "Must exist on the server the base URL points at (e.g. gpt-4o-mini on OpenAI, a llama model on a self-hosted server).",
186
+ },
187
+ {
188
+ key: "anthropic_api_key",
138
189
  type: "secret",
139
190
  default: "",
140
191
  group: "ai",
141
- label: "Groq API key (fallback)",
142
- hint: "Used only when Gemini is out of quota.",
192
+ label: "Anthropic API key",
193
+ hint: "Used when the provider is anthropic. Create one at console.anthropic.com.",
194
+ },
195
+ {
196
+ key: "anthropic_base_url",
197
+ type: "string",
198
+ default: "https://api.anthropic.com",
199
+ group: "ai",
200
+ label: "Anthropic base URL",
201
+ hint: "The host root — /v1/messages is appended. Change it only for a proxy or a compatible gateway.",
143
202
  },
144
203
  {
145
- key: "groq_model",
204
+ key: "anthropic_model",
146
205
  type: "string",
147
- default: "llama-3.3-70b-versatile",
206
+ default: "claude-sonnet-4-5",
148
207
  group: "ai",
149
- label: "Groq model",
208
+ label: "Anthropic model",
150
209
  },
151
210
  {
152
211
  key: "google_search_api_key",
@@ -466,6 +525,7 @@ function describe() {
466
525
  min: definition.min ?? null,
467
526
  max: definition.max ?? null,
468
527
  choices: definition.choices ?? null,
528
+ choiceLabels: definition.choiceLabels ?? null,
469
529
  restart: definition.restart === true,
470
530
  source: sourceOf(definition.key),
471
531
  };
package/src/core/proxy.js CHANGED
@@ -25,9 +25,9 @@
25
25
  // WHAT IS NOT PROXIED
26
26
  // -------------------
27
27
  // Nothing global is patched. The agents are handed to one `makeWASocket()`
28
- // call by the session manager, so the dashboard's own HTTP server, Gemini,
29
- // Groq and every other outbound request keep using the direct connection they
30
- // use today.
28
+ // call by the session manager, so the dashboard's own HTTP server, the AI
29
+ // providers and every other outbound request keep using the direct connection
30
+ // they use today.
31
31
  //
32
32
  // SECRETS
33
33
  // -------
@@ -0,0 +1,100 @@
1
+ const session = require("express-session");
2
+
3
+ function clone(value) {
4
+ return value == null ? value : JSON.parse(JSON.stringify(value));
5
+ }
6
+
7
+ class PanelSessionStore extends session.Store {
8
+ constructor({ ttlMs = 12 * 60 * 60 * 1000, maxSessions = 128 } = {}) {
9
+ super();
10
+ this.ttlMs = ttlMs;
11
+ this.maxSessions = maxSessions;
12
+ this.sessions = new Map();
13
+ }
14
+
15
+ _expiresAt(sess) {
16
+ const expires = Date.parse(sess?.cookie?.expires || "");
17
+ if (Number.isFinite(expires)) return expires;
18
+
19
+ const maxAge = Number(sess?.cookie?.maxAge);
20
+ if (Number.isFinite(maxAge) && maxAge > 0) return Date.now() + maxAge;
21
+
22
+ return Date.now() + this.ttlMs;
23
+ }
24
+
25
+ _prune() {
26
+ const now = Date.now();
27
+ for (const [sid, entry] of this.sessions) {
28
+ if (entry.expiresAt <= now) this.sessions.delete(sid);
29
+ }
30
+ }
31
+
32
+ _makeRoom(sid) {
33
+ if (this.sessions.has(sid) || this.sessions.size < this.maxSessions) return;
34
+
35
+ let oldestSid = null;
36
+ let oldestExpiry = Infinity;
37
+ for (const [candidateSid, entry] of this.sessions) {
38
+ if (entry.expiresAt < oldestExpiry) {
39
+ oldestSid = candidateSid;
40
+ oldestExpiry = entry.expiresAt;
41
+ }
42
+ }
43
+ if (oldestSid) this.sessions.delete(oldestSid);
44
+ }
45
+
46
+ get(sid, callback) {
47
+ try {
48
+ this._prune();
49
+ const entry = this.sessions.get(sid);
50
+ callback(null, entry ? clone(entry.session) : null);
51
+ } catch (error) {
52
+ callback(error);
53
+ }
54
+ }
55
+
56
+ set(sid, sess, callback = () => {}) {
57
+ try {
58
+ this._prune();
59
+ this._makeRoom(sid);
60
+ this.sessions.set(sid, {
61
+ session: clone(sess),
62
+ expiresAt: this._expiresAt(sess),
63
+ });
64
+ callback(null);
65
+ } catch (error) {
66
+ callback(error);
67
+ }
68
+ }
69
+
70
+ touch(sid, sess, callback = () => {}) {
71
+ try {
72
+ this._prune();
73
+ const entry = this.sessions.get(sid);
74
+ if (entry) {
75
+ entry.expiresAt = this._expiresAt(sess);
76
+ if (sess?.cookie) entry.session.cookie = clone(sess.cookie);
77
+ }
78
+ callback(null);
79
+ } catch (error) {
80
+ callback(error);
81
+ }
82
+ }
83
+
84
+ destroy(sid, callback = () => {}) {
85
+ this.sessions.delete(sid);
86
+ callback(null);
87
+ }
88
+
89
+ clear(callback = () => {}) {
90
+ this.sessions.clear();
91
+ callback(null);
92
+ }
93
+
94
+ length(callback) {
95
+ this._prune();
96
+ callback(null, this.sessions.size);
97
+ }
98
+ }
99
+
100
+ module.exports = { PanelSessionStore };
@@ -48,7 +48,10 @@ const settings = require("../config/settings.cjs");
48
48
  const memory = require("../utils/memory.cjs");
49
49
  const secrets = require("../config/secrets.cjs");
50
50
  const { DATA_DIR } = require("../config/paths.cjs");
51
- const { PERSONA_FILE } = require("../services/aiAgent.cjs");
51
+ const {
52
+ PERSONA_FILE,
53
+ activeProviderKeySetting,
54
+ } = require("../services/aiAgent.cjs");
52
55
  const { deleteScheduledJob, retryScheduledJob } = require("../../scheduler.cjs");
53
56
  const { describeScheduledJob } = require("../utils/recurrence.cjs");
54
57
 
@@ -140,7 +143,9 @@ router.get("/stats", (req, res) => {
140
143
 
141
144
  prefix: runtimeConfig.getPrefix(),
142
145
  commandCount: getCommandCatalog().length,
143
- aiConfigured: !!settings.get("gemini_api_key"),
146
+ // The active provider's key — "AI key: Not set" must mean what the
147
+ // bot will actually use, not whatever gemini_api_key happens to hold.
148
+ aiConfigured: !!settings.get(activeProviderKeySetting()),
144
149
 
145
150
  uptime: process.uptime(),
146
151
  memory: process.memoryUsage(),
@@ -22,6 +22,10 @@ const aiIdentity = require("../config/ai-identity.cjs");
22
22
  const settings = require("../config/settings.cjs");
23
23
  const memory = require("../utils/memory.cjs");
24
24
  const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
25
+ const {
26
+ runProviderAgent,
27
+ activeProviderKeySetting,
28
+ } = require("./aiProviders.cjs");
25
29
 
26
30
  // Model, key, tool budget and history length all come from config/settings.cjs
27
31
  // (what the dashboard saved, else the default) and are read per call, so changing
@@ -36,13 +40,18 @@ const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
36
40
  // client whose model and per-request configuration are passed at call time —
37
41
  // which suits Levix, because the model and the tool set are settings that may
38
42
  // change between one message and the next.
39
- let clientCache = { key: null, client: null };
43
+ let clientCache = { key: null, baseUrl: null, client: null };
40
44
 
41
45
  function geminiClient() {
42
46
  const key = settings.get("gemini_api_key");
43
47
  if (!key) return null;
44
- if (clientCache.key !== key) {
45
- clientCache = { key, client: new GoogleGenAI({ apiKey: key }) };
48
+ const baseUrl = String(settings.get("gemini_base_url")).replace(/\/+$/, "");
49
+ if (clientCache.key !== key || clientCache.baseUrl !== baseUrl) {
50
+ clientCache = {
51
+ key,
52
+ baseUrl,
53
+ client: new GoogleGenAI({ apiKey: key, httpOptions: { baseUrl } }),
54
+ };
46
55
  }
47
56
  return clientCache.client;
48
57
  }
@@ -225,7 +234,9 @@ function withTimeout(promise, ms, label) {
225
234
  }
226
235
 
227
236
  function isAgentEnabled() {
228
- return Boolean(geminiClient()) && settings.get("ai_agent");
237
+ // The key that matters is the active provider's: a panel switch from gemini
238
+ // to openai must not keep the agent gated on (or running on) a Gemini key.
239
+ return Boolean(settings.get(activeProviderKeySetting())) && settings.get("ai_agent");
229
240
  }
230
241
 
231
242
  // ===========================================================================
@@ -247,7 +258,7 @@ function isAgentEnabled() {
247
258
 
248
259
  const GOOGLE_SEARCH_TOOL = Object.freeze({ googleSearch: {} });
249
260
 
250
- /** Gemini-only. Nothing here is offered to Groq or any other provider. */
261
+ /** Gemini-only. The openai and anthropic providers are never offered it. */
251
262
  function googleSearchEnabled() {
252
263
  return settings.get("ai_google_search") === true;
253
264
  }
@@ -368,6 +379,10 @@ function formatSources(sources) {
368
379
  /**
369
380
  * Run one agent turn.
370
381
  *
382
+ * Dispatches on the `ai_provider` setting: gemini runs the native loop below
383
+ * (built-in Google Search, Files uploads); openai and anthropic run the same
384
+ * loop over their own wire formats in aiProviders.cjs.
385
+ *
371
386
  * @param {object} options
372
387
  * @param {Array} options.parts - the Gemini parts for this message
373
388
  * @param {Array} [options.history]- prior conversation
@@ -384,6 +399,20 @@ async function runAgent({
384
399
  useTools = true,
385
400
  maxSteps = null,
386
401
  } = {}) {
402
+ const provider = settings.get("ai_provider");
403
+ if (provider !== "gemini") {
404
+ const result = await runProviderAgent(provider, {
405
+ parts,
406
+ history: trimHistory(history),
407
+ systemInstruction: buildSystemInstruction(context),
408
+ status,
409
+ context,
410
+ useTools,
411
+ maxSteps,
412
+ });
413
+ return { ...result, history: trimHistory(result.history) };
414
+ }
415
+
387
416
  const genAI = geminiClient();
388
417
  if (!genAI) throw new Error("GEMINI_API_KEY غير معرف");
389
418
 
@@ -540,6 +569,7 @@ module.exports = {
540
569
  buildSystemInstruction,
541
570
  loadPersona,
542
571
  isAgentEnabled,
572
+ activeProviderKeySetting,
543
573
  isFileReferenceError,
544
574
  sanitizeHistoryForFiles,
545
575
  trimHistory,