levix-bot 3.3.0-rc.2 → 4.0.0-beta

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 (118) hide show
  1. package/README.md +11 -7
  2. package/app.cjs +42 -45
  3. package/package.json +9 -1
  4. package/public/dashboard/assets/index-C1b7ijfu.js +391 -0
  5. package/public/dashboard/assets/index-pYaivHON.css +1 -0
  6. package/public/dashboard/index.html +19 -0
  7. package/public/dashboard.css +391 -66
  8. package/public/dashboard.js +593 -42
  9. package/scheduler.cjs +9 -14
  10. package/src/auth/use-database-auth-state.js +2 -4
  11. package/src/bootstrap/core.js +2 -5
  12. package/src/bootstrap/panel.js +10 -5
  13. package/src/cli.js +3 -5
  14. package/src/commands/autoschedule.cjs +1 -4
  15. package/src/commands/blacklist.cjs +22 -20
  16. package/src/commands/block.cjs +2 -4
  17. package/src/commands/calc.cjs +9 -16
  18. package/src/commands/digit.cjs +97 -0
  19. package/src/commands/gemini.cjs +147 -161
  20. package/src/commands/group/add.cjs +7 -16
  21. package/src/commands/group/antiSpam.cjs +5 -11
  22. package/src/commands/group/antilink.cjs +34 -28
  23. package/src/commands/group/approveall.cjs +3 -11
  24. package/src/commands/group/clearwarns.cjs +2 -5
  25. package/src/commands/group/demote.cjs +3 -8
  26. package/src/commands/group/kick.cjs +12 -15
  27. package/src/commands/group/media.cjs +17 -24
  28. package/src/commands/group/members.cjs +1 -2
  29. package/src/commands/group/promote.cjs +4 -12
  30. package/src/commands/group/removeall.cjs +2 -5
  31. package/src/commands/group/setpp.cjs +1 -1
  32. package/src/commands/group/setrules.cjs +2 -8
  33. package/src/commands/group/setwarn.cjs +4 -2
  34. package/src/commands/group/tagadmins.cjs +2 -5
  35. package/src/commands/group/warn.cjs +26 -24
  36. package/src/commands/group/warns.cjs +1 -2
  37. package/src/commands/group/welcome.cjs +13 -17
  38. package/src/commands/group.cjs +7 -10
  39. package/src/commands/help.cjs +6 -12
  40. package/src/commands/lang.cjs +104 -0
  41. package/src/commands/listschedules.cjs +2 -6
  42. package/src/commands/loop.cjs +6 -13
  43. package/src/commands/memory.cjs +19 -32
  44. package/src/commands/mod.cjs +97 -35
  45. package/src/commands/perm.cjs +18 -56
  46. package/src/commands/ping.cjs +1 -5
  47. package/src/commands/poll.cjs +1 -1
  48. package/src/commands/rand.cjs +2 -5
  49. package/src/commands/schedule.cjs +4 -8
  50. package/src/commands/score.cjs +8 -2
  51. package/src/commands/setprefix.cjs +2 -2
  52. package/src/commands/shortlink.cjs +7 -15
  53. package/src/commands/shutdown.cjs +1 -1
  54. package/src/commands/sticker.cjs +222 -0
  55. package/src/commands/stt.cjs +136 -68
  56. package/src/commands/todo.cjs +9 -15
  57. package/src/commands/tts.cjs +10 -16
  58. package/src/commands/unblock.cjs +2 -6
  59. package/src/commands/weather.cjs +5 -12
  60. package/src/config/ai-persona.md +5 -3
  61. package/src/config/available-models.json +1156 -0
  62. package/src/config/baileys.config.js +2 -1
  63. package/src/config/brand.esm.js +1 -10
  64. package/src/config/constants.js +1 -1
  65. package/src/config/defaults.cjs +66 -66
  66. package/src/config/lock.cjs +2 -2
  67. package/src/config/modelCapabilities.cjs +172 -0
  68. package/src/config/runtime-config.cjs +8 -13
  69. package/src/config/secrets.cjs +18 -10
  70. package/src/config/settings.cjs +94 -10
  71. package/src/config/vault.cjs +107 -0
  72. package/src/core/connection.js +13 -12
  73. package/src/core/events.js +41 -23
  74. package/src/core/proxy.js +7 -7
  75. package/src/core/session.js +91 -48
  76. package/src/core/socket.js +12 -15
  77. package/src/db/db.cjs +14 -6
  78. package/src/db/store.cjs +221 -87
  79. package/src/domain/caddy.js +1 -1
  80. package/src/domain/command.js +40 -19
  81. package/src/domain/detect.js +1 -1
  82. package/src/domain/system.js +2 -5
  83. package/src/handlers/command.handler.js +22 -43
  84. package/src/handlers/group.handler.js +13 -13
  85. package/src/handlers/message.handler.js +11 -21
  86. package/src/index.js +8 -4
  87. package/src/middleware/antispam.middleware.js +46 -23
  88. package/src/middleware/blacklist.middleware.js +14 -9
  89. package/src/middleware/forward-tracking.middleware.js +3 -2
  90. package/src/middleware/permissions.middleware.js +7 -10
  91. package/src/panel/login-throttle.cjs +43 -0
  92. package/src/panel/session-auth.cjs +17 -0
  93. package/src/routes/dashboard.api.esm.js +394 -72
  94. package/src/services/aiAgent.cjs +136 -49
  95. package/src/services/aiProviders.cjs +193 -47
  96. package/src/services/aiRouter.cjs +137 -0
  97. package/src/services/aiTools.cjs +305 -125
  98. package/src/services/llmDiscovery.cjs +481 -0
  99. package/src/services/modelRegistry.cjs +412 -0
  100. package/src/utils/datetime.cjs +2 -2
  101. package/src/utils/lid-helper.esm.js +7 -7
  102. package/src/utils/logger.cjs +1 -1
  103. package/src/utils/markdownParser.cjs +159 -0
  104. package/src/utils/memory.cjs +43 -13
  105. package/src/utils/messageContent.cjs +40 -0
  106. package/src/utils/normalizeJid.esm.js +7 -7
  107. package/src/utils/numerals.cjs +75 -0
  108. package/src/utils/permissions.esm.js +10 -15
  109. package/src/utils/providerUrl.cjs +281 -0
  110. package/src/utils/recurrence.cjs +7 -11
  111. package/src/utils/sendBotMessage.esm.js +4 -14
  112. package/src/utils/statusMessage.cjs +5 -13
  113. package/src/utils/storage.cjs +10 -0
  114. package/src/utils/storage.esm.js +11 -0
  115. package/src/utils/textDecode.cjs +4 -9
  116. package/src/utils/thumbnail.cjs +8 -14
  117. package/src/utils/typing.esm.js +12 -12
  118. package/views/dashboard.ejs +134 -106
@@ -21,11 +21,18 @@ const logger = require("../utils/logger.cjs");
21
21
  const aiIdentity = require("../config/ai-identity.cjs");
22
22
  const settings = require("../config/settings.cjs");
23
23
  const memory = require("../utils/memory.cjs");
24
+ const {
25
+ assertProviderBaseUrl,
26
+ assertProviderRequestUrl,
27
+ } = require("../utils/providerUrl.cjs");
24
28
  const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
25
29
  const {
26
- runProviderAgent,
27
- activeProviderKeySetting,
28
- } = require("./aiProviders.cjs");
30
+ BaseAIProvider,
31
+ registerProvider,
32
+ getProvider,
33
+ detectModelCapabilities,
34
+ } = require("./aiRouter.cjs");
35
+ const { runProviderAgent, activeProviderKeySetting } = require("./aiProviders.cjs");
29
36
 
30
37
  // Model, key, tool budget and history length all come from config/settings.cjs
31
38
  // (what the dashboard saved, else the default) and are read per call, so changing
@@ -45,7 +52,9 @@ let clientCache = { key: null, baseUrl: null, client: null };
45
52
  function geminiClient() {
46
53
  const key = settings.get("gemini_api_key");
47
54
  if (!key) return null;
48
- const baseUrl = String(settings.get("gemini_base_url")).replace(/\/+$/, "");
55
+ const baseUrl = assertProviderBaseUrl(settings.get("gemini_base_url"), {
56
+ allowLoopback: true,
57
+ });
49
58
  if (clientCache.key !== key || clientCache.baseUrl !== baseUrl) {
50
59
  clientCache = {
51
60
  key,
@@ -100,7 +109,12 @@ function loadPersona() {
100
109
  const raw = fs.readFileSync(PERSONA_FILE, "utf8").trim();
101
110
  // Everything above the `---` separator is a note to the human editing the
102
111
  // file, not part of the prompt.
103
- const body = raw.includes("\n---") ? raw.split(/\n---\s*\n/).slice(1).join("\n---\n") : raw;
112
+ const body = raw.includes("\n---")
113
+ ? raw
114
+ .split(/\n---\s*\n/)
115
+ .slice(1)
116
+ .join("\n---\n")
117
+ : raw;
104
118
  personaCache = { text: body.trim() || DEFAULT_PERSONA, stamp };
105
119
  return personaCache.text;
106
120
  } catch {
@@ -120,6 +134,17 @@ function nowLabel() {
120
134
  }
121
135
  }
122
136
 
137
+ function botLangDirective() {
138
+ const botLang = settings.get("bot_language") || "auto";
139
+ if (botLang === "ar") {
140
+ return "- Language instruction: Always reply in Modern Standard Arabic (العربية الفصحى), clear, professional, and well-phrased.";
141
+ }
142
+ if (botLang === "en") {
143
+ return "- Language instruction: Always reply in English, clear, professional, natural, and direct.";
144
+ }
145
+ return "- Language instruction: Automatically reply in the language the user writes in (Modern Standard Arabic if Arabic, English if English).";
146
+ }
147
+
123
148
  /**
124
149
  * product identity + persona + who/where/when + long-term memory.
125
150
  *
@@ -129,15 +154,7 @@ function nowLabel() {
129
154
  * src/config/ai-identity.cjs) and is never rendered by the panel.
130
155
  */
131
156
  function buildSystemInstruction(context = {}) {
132
- const {
133
- chatId,
134
- senderName,
135
- senderId,
136
- isOwner,
137
- isAdmin,
138
- isGroup,
139
- chatName,
140
- } = context;
157
+ const { chatId, senderName, senderId, isOwner, isAdmin, isGroup, chatName } = context;
141
158
 
142
159
  const runtime = [
143
160
  "# Current context",
@@ -148,13 +165,12 @@ function buildSystemInstruction(context = {}) {
148
165
  isGroup
149
166
  ? "- This is a group: every participant sees your reply. Say nothing here that belongs to one person."
150
167
  : "- This is a private chat: only this person sees your reply.",
168
+ botLangDirective(),
151
169
  ].join("\n");
152
170
 
153
171
  const memoryBlock = memory.buildMemoryContext(chatId);
154
172
 
155
- return [aiIdentity.systemBlock, loadPersona(), runtime, memoryBlock]
156
- .filter(Boolean)
157
- .join("\n\n");
173
+ return [aiIdentity.systemBlock, loadPersona(), runtime, memoryBlock].filter(Boolean).join("\n\n");
158
174
  }
159
175
 
160
176
  /** A turn the conversation can legally start from. */
@@ -188,11 +204,7 @@ function trimHistory(history) {
188
204
  function isFileReferenceError(error) {
189
205
  if (!error) return false;
190
206
  const status =
191
- error.status ||
192
- error.statusCode ||
193
- error.code ||
194
- error.response?.status ||
195
- error.error?.code;
207
+ error.status || error.statusCode || error.code || error.response?.status || error.error?.code;
196
208
  const message = `${error.message || ""} ${error.details || ""}`.toLowerCase();
197
209
 
198
210
  if ([403, 404, 410].includes(Number(status))) return true;
@@ -202,7 +214,7 @@ function isFileReferenceError(error) {
202
214
  return (
203
215
  message.includes("file") &&
204
216
  /not found|not exist|expired|not in an active|not active|permission to access|forbidden|file_?uri/.test(
205
- message
217
+ message,
206
218
  )
207
219
  );
208
220
  }
@@ -227,7 +239,7 @@ function withTimeout(promise, ms, label) {
227
239
  new Promise((_, reject) => {
228
240
  timer = setTimeout(
229
241
  () => reject(new Error(`${label} تأخرت أكتر من ${Math.round(ms / 1000)} ثانية`)),
230
- ms
242
+ ms,
231
243
  );
232
244
  }),
233
245
  ]).finally(() => clearTimeout(timer));
@@ -391,33 +403,109 @@ function formatSources(sources) {
391
403
  * @param {boolean} [options.useTools=true]
392
404
  * @returns {Promise<{text: string, history: Array, toolCalls: Array, steps: number}>}
393
405
  */
394
- async function runAgent({
406
+ class GeminiProvider extends BaseAIProvider {
407
+ constructor() {
408
+ super({
409
+ id: "gemini",
410
+ label: "Google Gemini",
411
+ keySetting: "gemini_api_key",
412
+ modelSetting: "gemini_model",
413
+ baseUrlSetting: "gemini_base_url",
414
+ defaultBaseUrl: "",
415
+ supportsFiles: true,
416
+ });
417
+ }
418
+
419
+ async prepareMedia(
420
+ parts,
421
+ mediaMessage,
422
+ mimeOverride,
423
+ { downloadContentFromMessage, genAI, tempDir } = {},
424
+ ) {
425
+ if (!genAI) throw new Error("Gemini client unavailable");
426
+ const mime = mimeOverride || mediaMessage?.mimetype || "";
427
+ const caps = detectModelCapabilities("gemini");
428
+ if (mime.startsWith("image/") && (!settings.get("ai_vision_enabled") || !caps.supportsVision)) {
429
+ parts.push({
430
+ text: "[تم إرفاق صورة — الرؤية غير مفعّلة للنموذج المحدد / Vision is disabled for the selected model]",
431
+ });
432
+ return null;
433
+ }
434
+ if (mime.startsWith("audio/") && (!settings.get("ai_stt_enabled") || !caps.supportsAudioStt)) {
435
+ parts.push({
436
+ text: "[تم إرفاق صوت — تحويل الكلام غير مفعّل للنموذج المحدد / Speech-to-text is disabled for the selected model]",
437
+ });
438
+ return null;
439
+ }
440
+ const tempFilePath = path.join(tempDir || __dirname, `temp_media_${Date.now()}`);
441
+ const stream = await downloadContentFromMessage(
442
+ mediaMessage,
443
+ mediaMessage.mimetype?.startsWith("image/")
444
+ ? "image"
445
+ : mediaMessage.mimetype?.startsWith("video/")
446
+ ? "video"
447
+ : mediaMessage.mimetype?.startsWith("audio/")
448
+ ? "audio"
449
+ : "document",
450
+ );
451
+ let buffer = Buffer.from([]);
452
+ for await (const chunk of stream) buffer = Buffer.concat([buffer, chunk]);
453
+ if (!buffer.length) throw new Error("Empty media buffer");
454
+ await fs.promises.writeFile(tempFilePath, buffer);
455
+
456
+ try {
457
+ const uploadResult = await genAI.files.upload({
458
+ file: tempFilePath,
459
+ mimeType: mediaMessage.mimetype,
460
+ });
461
+ parts.push({
462
+ fileData: {
463
+ fileUri: uploadResult.uri,
464
+ mimeType: uploadResult.mimeType,
465
+ },
466
+ });
467
+ return uploadResult.uri;
468
+ } finally {
469
+ await fs.promises.unlink(tempFilePath).catch(() => {});
470
+ }
471
+ }
472
+
473
+ async runTurn(options) {
474
+ return runGeminiTurn(options);
475
+ }
476
+ }
477
+
478
+ registerProvider("gemini", new GeminiProvider());
479
+
480
+ /**
481
+ * Run one agent turn.
482
+ * Modular dispatch through getProvider().
483
+ */
484
+ async function runAgent(options = {}) {
485
+ const provider = getProvider();
486
+ const result = await provider.runTurn({
487
+ ...options,
488
+ history: trimHistory(options.history),
489
+ systemInstruction: buildSystemInstruction(options.context),
490
+ });
491
+ return { ...result, history: trimHistory(result.history) };
492
+ }
493
+
494
+ async function runGeminiTurn({
395
495
  parts,
396
496
  history = [],
397
497
  status = null,
398
498
  context = {},
399
499
  useTools = true,
400
500
  maxSteps = null,
501
+ systemInstruction = null,
401
502
  } = {}) {
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
-
416
503
  const genAI = geminiClient();
417
504
  if (!genAI) throw new Error("GEMINI_API_KEY غير معرف");
505
+ await assertProviderRequestUrl(settings.get("gemini_base_url"), { allowLoopback: true });
418
506
 
419
507
  const stepBudget = maxSteps ?? settings.get("ai_max_tool_steps");
420
- const systemInstruction = buildSystemInstruction(context);
508
+ const instruction = systemInstruction || buildSystemInstruction(context);
421
509
  const trimmed = trimHistory(history);
422
510
  const model = settings.get("gemini_model");
423
511
 
@@ -435,7 +523,7 @@ async function runAgent({
435
523
  model,
436
524
  history: trimmed,
437
525
  config: {
438
- systemInstruction,
526
+ systemInstruction: instruction,
439
527
  ...(tools.length ? { tools } : {}),
440
528
  ...(toolConfig ? { toolConfig } : {}),
441
529
  },
@@ -459,7 +547,7 @@ async function runAgent({
459
547
  if (!searchOffered || !isToolCombinationError(error)) throw error;
460
548
  logger.warn(
461
549
  { err: error?.message, model },
462
- "[aiAgent] this model refused Google Search together with function tools — retrying with functions only"
550
+ "[aiAgent] this model refused Google Search together with function tools — retrying with functions only",
463
551
  );
464
552
  searchOffered = false;
465
553
  chat = openChat(false);
@@ -486,10 +574,7 @@ async function runAgent({
486
574
  const responses = [];
487
575
  for (const call of calls) {
488
576
  toolCalls.push({ name: call.name, args: call.args });
489
- logger.info(
490
- { tool: call.name, chatId: context.chatId },
491
- "[aiAgent] tool call"
492
- );
577
+ logger.info({ tool: call.name, chatId: context.chatId }, "[aiAgent] tool call");
493
578
 
494
579
  // Named apart from the outer `response` on purpose: that one is the
495
580
  // model's reply and is reassigned at the bottom of this loop.
@@ -498,7 +583,7 @@ async function runAgent({
498
583
  toolResult = await withTimeout(
499
584
  runTool(call.name, call.args, context),
500
585
  settings.get("ai_tool_timeout_ms"),
501
- call.name
586
+ call.name,
502
587
  );
503
588
  } catch (err) {
504
589
  toolResult = { error: err?.message || String(err) };
@@ -532,8 +617,7 @@ async function runAgent({
532
617
  }
533
618
 
534
619
  if (!text && steps >= stepBudget) {
535
- text =
536
- "شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.";
620
+ text = "شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.";
537
621
  }
538
622
 
539
623
  let newHistory = [];
@@ -574,4 +658,7 @@ module.exports = {
574
658
  sanitizeHistoryForFiles,
575
659
  trimHistory,
576
660
  PERSONA_FILE,
661
+ detectModelCapabilities,
662
+ getProvider,
663
+ GeminiProvider,
577
664
  };
@@ -31,6 +31,7 @@
31
31
  const logger = require("../utils/logger.cjs");
32
32
  const settings = require("../config/settings.cjs");
33
33
  const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
34
+ const { assertProviderBaseUrl, safeProviderFetch } = require("../utils/providerUrl.cjs");
34
35
 
35
36
  // Shown in place of a media part, on the wire and in the canonical history.
36
37
  const MEDIA_NOTE = "[تم إرفاق ملف/وسائط في الرسالة]";
@@ -49,9 +50,7 @@ const ADAPTERS = {
49
50
  baseUrlSetting: "openai_base_url",
50
51
  defaultBaseUrl: "https://api.openai.com/v1",
51
52
  tools: openaiTools,
52
- systemMessage: (systemInstruction) => [
53
- { role: "system", content: systemInstruction },
54
- ],
53
+ systemMessage: (systemInstruction) => [{ role: "system", content: systemInstruction }],
55
54
  userMessage: (text) => ({ role: "user", content: text }),
56
55
  /** Canonical history -> chat-completions messages. */
57
56
  historyToMessages: historyToOpenAI,
@@ -97,6 +96,110 @@ const ADAPTERS = {
97
96
  },
98
97
  };
99
98
 
99
+ const {
100
+ BaseAIProvider,
101
+ registerProvider,
102
+ getProvider,
103
+ detectModelCapabilities,
104
+ } = require("./aiRouter.cjs");
105
+
106
+ function visionAllowed(providerId) {
107
+ const id = providerId || settings.get("ai_provider");
108
+ return Boolean(settings.get("ai_vision_enabled") && detectModelCapabilities(id).supportsVision);
109
+ }
110
+
111
+ async function prepareInlineImageMedia(
112
+ providerId,
113
+ parts,
114
+ mediaMessage,
115
+ mimeOverride,
116
+ downloadContentFromMessage,
117
+ ) {
118
+ const isVisionOn = visionAllowed(providerId);
119
+ const mime = mimeOverride || mediaMessage?.mimetype || "ملف";
120
+ if (isVisionOn && mime.startsWith("image/") && typeof downloadContentFromMessage === "function") {
121
+ try {
122
+ const stream = await downloadContentFromMessage(mediaMessage, "image");
123
+ let buffer = Buffer.from([]);
124
+ for await (const chunk of stream) buffer = Buffer.concat([buffer, chunk]);
125
+ if (buffer.length) {
126
+ parts.push({
127
+ inlineData: {
128
+ mimeType: mime,
129
+ data: buffer.toString("base64"),
130
+ },
131
+ });
132
+ return "inline:image";
133
+ }
134
+ } catch (err) {
135
+ logger.warn({ err }, `[${providerId}] failed to extract inline image for vision`);
136
+ }
137
+ }
138
+ parts.push({
139
+ text: `[تم إرفاق وسائط (${mime}) — المزود الحالي لا يستطيع قراءة هذا النوع من الوسائط أو تم تعطيل ميزة تحليل الصور]`,
140
+ });
141
+ return null;
142
+ }
143
+
144
+ class OpenAIProvider extends BaseAIProvider {
145
+ constructor() {
146
+ super({
147
+ id: "openai",
148
+ label: "OpenAI-compatible",
149
+ keySetting: "openai_api_key",
150
+ modelSetting: "openai_model",
151
+ baseUrlSetting: "openai_base_url",
152
+ defaultBaseUrl: "https://api.openai.com/v1",
153
+ supportsFiles: false,
154
+ });
155
+ }
156
+
157
+ async prepareMedia(parts, mediaMessage, mimeOverride, { downloadContentFromMessage } = {}) {
158
+ return prepareInlineImageMedia(
159
+ "openai",
160
+ parts,
161
+ mediaMessage,
162
+ mimeOverride,
163
+ downloadContentFromMessage,
164
+ );
165
+ }
166
+
167
+ async runTurn(options) {
168
+ return runProviderAgent("openai", options);
169
+ }
170
+ }
171
+
172
+ class AnthropicProvider extends BaseAIProvider {
173
+ constructor() {
174
+ super({
175
+ id: "anthropic",
176
+ label: "Anthropic",
177
+ keySetting: "anthropic_api_key",
178
+ modelSetting: "anthropic_model",
179
+ baseUrlSetting: "anthropic_base_url",
180
+ defaultBaseUrl: "https://api.anthropic.com",
181
+ supportsFiles: false,
182
+ });
183
+ }
184
+
185
+ async prepareMedia(parts, mediaMessage, mimeOverride, { downloadContentFromMessage } = {}) {
186
+ return prepareInlineImageMedia(
187
+ "anthropic",
188
+ parts,
189
+ mediaMessage,
190
+ mimeOverride,
191
+ downloadContentFromMessage,
192
+ );
193
+ }
194
+
195
+ async runTurn(options) {
196
+ return runProviderAgent("anthropic", options);
197
+ }
198
+ }
199
+
200
+ registerProvider("openai", new OpenAIProvider());
201
+ registerProvider("anthropic", new AnthropicProvider());
202
+
100
203
  function adapterFor(provider) {
101
204
  const adapter = ADAPTERS[provider];
102
205
  if (!adapter) throw new Error(`مزود غير معروف: ${provider}`);
@@ -160,15 +263,7 @@ function toJsonSchema(schema) {
160
263
  } else if (key === "anyOf") {
161
264
  out.anyOf = toJsonSchema(value);
162
265
  } else if (
163
- [
164
- "description",
165
- "enum",
166
- "required",
167
- "format",
168
- "minimum",
169
- "maximum",
170
- "default",
171
- ].includes(key)
266
+ ["description", "enum", "required", "format", "minimum", "maximum", "default"].includes(key)
172
267
  ) {
173
268
  out[key] = value;
174
269
  }
@@ -204,11 +299,17 @@ function anthropicTools() {
204
299
  // ===========================================================================
205
300
 
206
301
  function partsText(parts) {
302
+ const isVisionOn = visionAllowed();
207
303
  return (parts || [])
208
304
  .map((part) => {
209
305
  if (part?.text) return part.text;
210
- // Media on this path is a note, never bytes: there is no upload API to
211
- // send it through (see the header comment).
306
+ if (
307
+ isVisionOn &&
308
+ part?.inlineData?.mimeType?.startsWith("image/") &&
309
+ part?.inlineData?.data
310
+ ) {
311
+ return "";
312
+ }
212
313
  if (part?.fileData || part?.inlineData) return MEDIA_NOTE;
213
314
  return "";
214
315
  })
@@ -242,9 +343,7 @@ function historyToOpenAI(history) {
242
343
  (history || []).forEach((turn, turnIndex) => {
243
344
  if (!turn || !Array.isArray(turn.parts)) return;
244
345
  const role = turn.role === "model" ? "assistant" : "user";
245
- const calls = turn.parts
246
- .filter((part) => part?.functionCall)
247
- .map((part) => part.functionCall);
346
+ const calls = turn.parts.filter((part) => part?.functionCall).map((part) => part.functionCall);
248
347
  const responses = turn.parts
249
348
  .filter((part) => part?.functionResponse)
250
349
  .map((part) => part.functionResponse);
@@ -268,6 +367,29 @@ function historyToOpenAI(history) {
268
367
  return;
269
368
  }
270
369
 
370
+ const isVisionOn = visionAllowed("openai");
371
+ const imageParts =
372
+ isVisionOn && role === "user"
373
+ ? turn.parts.filter(
374
+ (p) => p?.inlineData?.mimeType?.startsWith("image/") && p?.inlineData?.data,
375
+ )
376
+ : [];
377
+
378
+ let userContent = text;
379
+ if (imageParts.length) {
380
+ const items = [];
381
+ if (text) items.push({ type: "text", text });
382
+ for (const img of imageParts) {
383
+ items.push({
384
+ type: "image_url",
385
+ image_url: {
386
+ url: `data:${img.inlineData.mimeType};base64,${img.inlineData.data}`,
387
+ },
388
+ });
389
+ }
390
+ userContent = items;
391
+ }
392
+
271
393
  if (responses.length) {
272
394
  for (const response of responses) {
273
395
  messages.push({
@@ -276,11 +398,11 @@ function historyToOpenAI(history) {
276
398
  content: JSON.stringify(response.response ?? {}),
277
399
  });
278
400
  }
279
- if (text) messages.push({ role: "user", content: text });
401
+ if (userContent) messages.push({ role: "user", content: userContent });
280
402
  return;
281
403
  }
282
404
 
283
- if (text) messages.push({ role, content: text });
405
+ if (userContent) messages.push({ role, content: userContent });
284
406
  });
285
407
 
286
408
  return messages;
@@ -330,7 +452,23 @@ function historyToAnthropic(history) {
330
452
  content: JSON.stringify(part.functionResponse.response ?? {}),
331
453
  });
332
454
  } else if (part?.fileData || part?.inlineData) {
333
- blocks.push({ type: "text", text: MEDIA_NOTE });
455
+ const isVisionOn = visionAllowed("anthropic");
456
+ if (
457
+ isVisionOn &&
458
+ part?.inlineData?.mimeType?.startsWith("image/") &&
459
+ part?.inlineData?.data
460
+ ) {
461
+ blocks.push({
462
+ type: "image",
463
+ source: {
464
+ type: "base64",
465
+ media_type: part.inlineData.mimeType,
466
+ data: part.inlineData.data,
467
+ },
468
+ });
469
+ } else {
470
+ blocks.push({ type: "text", text: MEDIA_NOTE });
471
+ }
334
472
  }
335
473
  }
336
474
 
@@ -352,8 +490,8 @@ function parseOpenAIResponse(payload) {
352
490
  const content = Array.isArray(message.content)
353
491
  ? message.content.map((block) => block?.text || "").join("\n")
354
492
  : typeof message.content === "string"
355
- ? message.content
356
- : "";
493
+ ? message.content
494
+ : "";
357
495
 
358
496
  const calls = (message.tool_calls || [])
359
497
  .map((toolCall, i) => ({
@@ -377,7 +515,7 @@ function parseOpenAIResponse(payload) {
377
515
 
378
516
  function parseAnthropicResponse(payload) {
379
517
  const blocks = (payload?.content || []).filter(
380
- (block) => block?.type === "text" || block?.type === "tool_use"
518
+ (block) => block?.type === "text" || block?.type === "tool_use",
381
519
  );
382
520
 
383
521
  const calls = blocks
@@ -411,17 +549,17 @@ async function callApi({ url, headers, body, label }) {
411
549
 
412
550
  let response;
413
551
  try {
414
- response = await fetch(url, {
552
+ response = await safeProviderFetch(url, {
415
553
  method: "POST",
416
554
  headers: { "Content-Type": "application/json", ...headers },
417
555
  body: JSON.stringify(body),
418
556
  signal: controller.signal,
419
- });
557
+ }, { allowLoopback: true });
420
558
  } catch (err) {
421
559
  throw new Error(
422
560
  err?.name === "AbortError"
423
561
  ? `${label} API request timed out after ${API_TIMEOUT_MS / 1000}s`
424
- : `${label} API request failed: ${err?.message || err}`
562
+ : `${label} API request failed: ${err?.message || err}`,
425
563
  );
426
564
  } finally {
427
565
  clearTimeout(timer);
@@ -445,7 +583,11 @@ async function callApi({ url, headers, body, label }) {
445
583
  return payload;
446
584
  }
447
585
 
448
- function requestFor(adapter, provider, { baseUrl, apiKey, model, systemInstruction, messages, tools }) {
586
+ function requestFor(
587
+ adapter,
588
+ provider,
589
+ { baseUrl, apiKey, model, systemInstruction, messages, tools },
590
+ ) {
449
591
  if (provider === "anthropic") {
450
592
  return {
451
593
  url: `${baseUrl}/v1/messages`,
@@ -481,8 +623,8 @@ function withTimeout(promise, ms, label) {
481
623
  promise,
482
624
  new Promise((_, reject) => {
483
625
  timer = setTimeout(
484
- () => reject(new Error(`${label} تأخرت أكتر من ${Math.round(ms / 1000)} ثانية`)),
485
- ms
626
+ () => reject(new Error(`${label} استغرقت العملية أكثر من ${Math.round(ms / 1000)} ثانية`)),
627
+ ms,
486
628
  );
487
629
  }),
488
630
  ]).finally(() => clearTimeout(timer));
@@ -505,15 +647,18 @@ function withTimeout(promise, ms, label) {
505
647
  * @param {boolean} [options.useTools=true]
506
648
  * @returns {Promise<{text: string, history: Array, toolCalls: Array, steps: number, sources: Array, searchOffered: boolean}>}
507
649
  */
508
- async function runProviderAgent(provider, {
509
- parts,
510
- systemInstruction,
511
- history = [],
512
- status = null,
513
- context = {},
514
- useTools = true,
515
- maxSteps = null,
516
- } = {}) {
650
+ async function runProviderAgent(
651
+ provider,
652
+ {
653
+ parts,
654
+ systemInstruction,
655
+ history = [],
656
+ status = null,
657
+ context = {},
658
+ useTools = true,
659
+ maxSteps = null,
660
+ } = {},
661
+ ) {
517
662
  const adapter = adapterFor(provider);
518
663
 
519
664
  const apiKey = settings.get(adapter.keySetting);
@@ -521,9 +666,10 @@ async function runProviderAgent(provider, {
521
666
 
522
667
  const stepBudget = maxSteps ?? settings.get("ai_max_tool_steps");
523
668
  const model = settings.get(adapter.modelSetting);
524
- const baseUrl = String(
525
- settings.get(adapter.baseUrlSetting) || adapter.defaultBaseUrl
526
- ).replace(/\/+$/, "");
669
+ const baseUrl = assertProviderBaseUrl(
670
+ settings.get(adapter.baseUrlSetting) || adapter.defaultBaseUrl,
671
+ { allowLoopback: true },
672
+ );
527
673
 
528
674
  const turnText = partsText(parts);
529
675
 
@@ -585,8 +731,7 @@ async function runProviderAgent(provider, {
585
731
  // Same contract as the Gemini loop: the budget answer, not a lie.
586
732
  return finish({
587
733
  text:
588
- parsed.text ||
589
- "شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.",
734
+ parsed.text || "شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.",
590
735
  canonical,
591
736
  toolCalls,
592
737
  steps,
@@ -596,9 +741,7 @@ async function runProviderAgent(provider, {
596
741
  steps += 1;
597
742
 
598
743
  if (status) {
599
- const line = parsed.calls
600
- .map((call) => describeCall(call.name, call.args))
601
- .join("\n");
744
+ const line = parsed.calls.map((call) => describeCall(call.name, call.args)).join("\n");
602
745
  await status.update(line);
603
746
  }
604
747
 
@@ -612,7 +755,7 @@ async function runProviderAgent(provider, {
612
755
  toolResult = await withTimeout(
613
756
  runTool(call.name, call.args, context),
614
757
  settings.get("ai_tool_timeout_ms"),
615
- call.name
758
+ call.name,
616
759
  );
617
760
  } catch (err) {
618
761
  toolResult = { error: err?.message || String(err) };
@@ -663,4 +806,7 @@ module.exports = {
663
806
  parseOpenAIResponse,
664
807
  parseAnthropicResponse,
665
808
  ADAPTERS,
809
+ getProvider,
810
+ OpenAIProvider,
811
+ AnthropicProvider,
666
812
  };