@intranefr/superbackend 1.4.3

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 (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,749 @@
1
+ const axios = require("axios");
2
+ const AuditEvent = require("../models/AuditEvent");
3
+ const GlobalSetting = require("../models/GlobalSetting");
4
+ const { decryptString } = require("../utils/encryption");
5
+
6
+ const PROVIDERS_KEY = "llm.providers";
7
+ const PROMPTS_KEY = "llm.prompts";
8
+
9
+ let cache = {
10
+ providers: null,
11
+ prompts: null,
12
+ ts: 0,
13
+ };
14
+
15
+ const CACHE_TTL = 60000;
16
+
17
+
18
+ function computeCompletionURL(baseUrl) {
19
+ const trimmed = baseUrl.replace(/\/$/, "");
20
+
21
+ // Perplexity: already exposes /chat/completions
22
+ if (trimmed.includes('perplex')) {
23
+ if (trimmed.endsWith('/chat/completions')) return trimmed;
24
+ return trimmed + "/chat/completions";
25
+ }
26
+
27
+ // If base already includes /v1 (e.g., https://openrouter.ai/api/v1), do not append another /v1
28
+ if (/(?:^|\/)v1$/.test(trimmed)) {
29
+ return trimmed + "/chat/completions";
30
+ }
31
+ if (trimmed.endsWith('/v1/chat/completions')) return trimmed;
32
+
33
+ return trimmed + "/v1/chat/completions";
34
+ }
35
+
36
+ async function loadConfig() {
37
+ const now = Date.now();
38
+ if (cache.ts && now - cache.ts < CACHE_TTL && cache.providers && cache.prompts) {
39
+ return { providers: cache.providers, prompts: cache.prompts };
40
+ }
41
+
42
+ const keys = [PROVIDERS_KEY, PROMPTS_KEY];
43
+ const docs = await GlobalSetting.find({ key: { $in: keys } })
44
+ .select("key value")
45
+ .lean();
46
+
47
+ const byKey = Array.isArray(docs)
48
+ ? Object.fromEntries(docs.map((d) => [d.key, d.value]))
49
+ : {};
50
+
51
+ let providers = {};
52
+ let prompts = {};
53
+
54
+ try {
55
+ providers = byKey[PROVIDERS_KEY]
56
+ ? JSON.parse(byKey[PROVIDERS_KEY])
57
+ : {};
58
+ } catch (e) {
59
+ providers = {};
60
+ }
61
+
62
+ try {
63
+ prompts = byKey[PROMPTS_KEY] ? JSON.parse(byKey[PROMPTS_KEY]) : {};
64
+ } catch (e) {
65
+ prompts = {};
66
+ }
67
+
68
+ // Load encrypted API keys for providers
69
+ try {
70
+ const apiKeyDocs = await GlobalSetting.find({
71
+ key: { $regex: /^llm\.provider\..+\.apiKey$/ },
72
+ type: "encrypted",
73
+ })
74
+ .select("key value type")
75
+ .lean();
76
+
77
+ if (Array.isArray(apiKeyDocs)) {
78
+ for (const doc of apiKeyDocs) {
79
+ const key = String(doc.key || "");
80
+ const match = key.match(/^llm\.provider\.(.+)\.apiKey$/);
81
+ if (!match) continue;
82
+ const providerKey = match[1];
83
+ if (!providerKey) continue;
84
+ try {
85
+ const payload = JSON.parse(doc.value);
86
+ const apiKey = decryptString(payload);
87
+ if (!providers[providerKey]) {
88
+ providers[providerKey] = {};
89
+ }
90
+ providers[providerKey].apiKey = apiKey;
91
+ } catch (e) {
92
+ // Ignore decryption errors for individual providers
93
+ }
94
+ }
95
+ }
96
+ } catch (e) {
97
+ // Do not fail overall config load if encrypted keys query fails
98
+ }
99
+
100
+ cache = { providers, prompts, ts: now };
101
+ return { providers, prompts };
102
+ }
103
+
104
+ function interpolateTemplate(template, variables) {
105
+ const v = variables || {};
106
+ return String(template || "").replace(/\{([^}]+)\}/g, (match, key) => {
107
+ const k = String(key || "").trim();
108
+ if (!Object.prototype.hasOwnProperty.call(v, k)) {
109
+ return "";
110
+ }
111
+ const value = v[k];
112
+ if (value === null || value === undefined) return "";
113
+ return String(value);
114
+ });
115
+ }
116
+
117
+ function normalizeProviderConfig(rawProviders) {
118
+ const providers = {};
119
+ const input = rawProviders && typeof rawProviders === "object" ? rawProviders : {};
120
+ for (const [key, value] of Object.entries(input)) {
121
+ if (!value || typeof value !== "object") continue;
122
+ const baseUrl = String(value.baseUrl || value.base_url || "").trim();
123
+ if (!baseUrl) continue;
124
+ providers[key] = {
125
+ key,
126
+ label: value.label || key,
127
+ preset: value.preset || "custom",
128
+ baseUrl,
129
+ apiKey: value.apiKey || value.api_key || "",
130
+ defaultModel: value.defaultModel || value.default_model || "",
131
+ enabled: value.enabled !== false,
132
+ modelPricing: value.modelPricing || value.model_pricing || {},
133
+ extraHeaders: value.extraHeaders || {},
134
+ timeoutMs: Number(value.timeoutMs || 60000),
135
+ };
136
+ }
137
+ return providers;
138
+ }
139
+
140
+ function normalizeUsage(rawUsage) {
141
+ if (!rawUsage || typeof rawUsage !== "object") return null;
142
+ const promptTokens = Number(rawUsage.prompt_tokens);
143
+ const completionTokens = Number(rawUsage.completion_tokens);
144
+ const totalTokens = Number(rawUsage.total_tokens);
145
+
146
+ const normalized = {
147
+ prompt_tokens: Number.isFinite(promptTokens) ? promptTokens : null,
148
+ completion_tokens: Number.isFinite(completionTokens) ? completionTokens : null,
149
+ total_tokens: Number.isFinite(totalTokens)
150
+ ? totalTokens
151
+ : (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)
152
+ ? promptTokens + completionTokens
153
+ : null),
154
+ };
155
+
156
+ if (rawUsage.cost !== undefined && rawUsage.cost !== null) {
157
+ const cost = Number(rawUsage.cost);
158
+ if (Number.isFinite(cost)) {
159
+ normalized.cost = cost;
160
+ }
161
+ }
162
+
163
+ if (rawUsage.is_byok !== undefined) {
164
+ normalized.is_byok = Boolean(rawUsage.is_byok);
165
+ }
166
+
167
+ normalized.raw = rawUsage;
168
+ return normalized;
169
+ }
170
+
171
+ function computeCostFromPricing({ prompt_tokens, completion_tokens }, modelPricing) {
172
+ if (!modelPricing || typeof modelPricing !== "object") return null;
173
+
174
+ const promptTokens = Number(prompt_tokens);
175
+ const completionTokens = Number(completion_tokens);
176
+ if (!Number.isFinite(promptTokens) && !Number.isFinite(completionTokens)) return null;
177
+
178
+ const inRate = Number(modelPricing.costPerMillionIn);
179
+ const outRate = Number(modelPricing.costPerMillionOut);
180
+ if (!Number.isFinite(inRate) && !Number.isFinite(outRate)) return null;
181
+
182
+ const costIn = Number.isFinite(promptTokens) && Number.isFinite(inRate)
183
+ ? (promptTokens / 1_000_000) * inRate
184
+ : 0;
185
+ const costOut = Number.isFinite(completionTokens) && Number.isFinite(outRate)
186
+ ? (completionTokens / 1_000_000) * outRate
187
+ : 0;
188
+
189
+ const total = costIn + costOut;
190
+ return Number.isFinite(total) ? total : null;
191
+ }
192
+
193
+ function normalizePrompts(rawPrompts) {
194
+ const prompts = {};
195
+ const input = rawPrompts && typeof rawPrompts === "object" ? rawPrompts : {};
196
+ for (const [key, value] of Object.entries(input)) {
197
+ if (!value || typeof value !== "object") continue;
198
+ const template = String(value.template || "");
199
+ if (!template) continue;
200
+ prompts[key] = {
201
+ key,
202
+ label: value.label || key,
203
+ description: value.description || "",
204
+ template,
205
+ providerKey: value.providerKey || value.provider || "",
206
+ model: value.model || "",
207
+ defaultOptions: value.defaultOptions || {},
208
+ inputSchema: value.inputSchema || null,
209
+ enabled: value.enabled !== false,
210
+ };
211
+ }
212
+ return prompts;
213
+ }
214
+
215
+ async function logAuditEntry({
216
+ promptKey,
217
+ providerKey,
218
+ model,
219
+ variables,
220
+ requestOptions,
221
+ outcome,
222
+ errorMessage,
223
+ usage,
224
+ }) {
225
+ try {
226
+ const event = new AuditEvent({
227
+ actorType: "system",
228
+ action: "llm.completion",
229
+ outcome: outcome || (errorMessage ? "failure" : "success"),
230
+ meta: {
231
+ promptKey,
232
+ providerKey,
233
+ model,
234
+ variables: variables || {},
235
+ requestOptions: requestOptions || {},
236
+ errorMessage,
237
+ usage: usage || null,
238
+ },
239
+ });
240
+ await event.save();
241
+ } catch (e) {
242
+ // Do not throw on audit failure
243
+ }
244
+ }
245
+
246
+
247
+
248
+ async function call(promptKey, variables = {}, runtimeOptions = {}) {
249
+ const { providers: rawProviders, prompts: rawPrompts } = await loadConfig();
250
+ const providers = normalizeProviderConfig(rawProviders);
251
+ const prompts = normalizePrompts(rawPrompts);
252
+
253
+ const prompt = prompts[promptKey];
254
+ if (!prompt || prompt.enabled === false) {
255
+ throw new Error("Prompt not found or disabled");
256
+ }
257
+
258
+ const provider = providers[prompt.providerKey];
259
+ if (!provider || provider.enabled === false || !provider.apiKey) {
260
+ throw new Error("Provider not found, disabled, or missing apiKey");
261
+ }
262
+
263
+ const mergedOptions = {
264
+ ...(prompt.defaultOptions || {}),
265
+ ...(runtimeOptions || {}),
266
+ };
267
+
268
+ const model =
269
+ mergedOptions.model || prompt.model || provider.defaultModel || "";
270
+ if (!model) {
271
+ throw new Error("Model is not configured");
272
+ }
273
+
274
+ const content = interpolateTemplate(prompt.template, variables);
275
+
276
+ const url = computeCompletionURL(provider.baseUrl);
277
+
278
+ const body = {
279
+ model,
280
+ messages: [
281
+ {
282
+ role: "user",
283
+ content,
284
+ },
285
+ ],
286
+ };
287
+
288
+ const allowedOptions = [
289
+ "temperature",
290
+ "top_p",
291
+ "max_tokens",
292
+ "presence_penalty",
293
+ "frequency_penalty",
294
+ "stop",
295
+ "n",
296
+ "stream",
297
+ ];
298
+
299
+ for (const key of allowedOptions) {
300
+ if (mergedOptions[key] !== undefined) {
301
+ body[key] = mergedOptions[key];
302
+ }
303
+ }
304
+
305
+ let response;
306
+ let text = "";
307
+ let usage = null;
308
+
309
+ try {
310
+ // Debug: log curl equivalent
311
+ const curlHeaders = [
312
+ `-H "Authorization: Bearer ${provider.apiKey}"`,
313
+ `-H "Content-Type: application/json"`,
314
+ ...Object.entries(provider.extraHeaders || {}).map(([k, v]) => `-H "${k}: ${v}"`),
315
+ ].join(' ');
316
+ console.log(`[llm.service] curl equivalent:\n curl -X POST ${url} ${curlHeaders} -d '${JSON.stringify(body)}'\n`);
317
+
318
+ response = await axios.post(url, body, {
319
+ headers: {
320
+ Authorization: `Bearer ${provider.apiKey}`,
321
+ "Content-Type": "application/json",
322
+ ...provider.extraHeaders,
323
+ },
324
+ timeout: provider.timeoutMs,
325
+ });
326
+
327
+ const data = response && response.data ? response.data : {};
328
+ const choice =
329
+ Array.isArray(data.choices) && data.choices.length > 0
330
+ ? data.choices[0]
331
+ : null;
332
+ text =
333
+ choice && choice.message && typeof choice.message.content === "string"
334
+ ? choice.message.content
335
+ : "";
336
+ const rawUsage = data.usage || null;
337
+ const normalized = normalizeUsage(rawUsage);
338
+
339
+ if (normalized && normalized.cost === undefined) {
340
+ const pricing =
341
+ provider.modelPricing && typeof provider.modelPricing === "object"
342
+ ? provider.modelPricing[model]
343
+ : null;
344
+ const computedCost = pricing
345
+ ? computeCostFromPricing(normalized, pricing)
346
+ : null;
347
+ if (computedCost !== null) {
348
+ normalized.cost = computedCost;
349
+ normalized.cost_source = "computed";
350
+ }
351
+ }
352
+
353
+ if (normalized && normalized.cost !== undefined && normalized.cost_source === undefined) {
354
+ normalized.cost_source = "provider";
355
+ }
356
+
357
+ usage = normalized;
358
+
359
+ await logAuditEntry({
360
+ promptKey,
361
+ providerKey: provider.key,
362
+ model,
363
+ variables,
364
+ requestOptions: mergedOptions,
365
+ outcome: "success",
366
+ usage,
367
+ });
368
+ } catch (error) {
369
+ const message =
370
+ (error.response && error.response.data && error.response.data.error &&
371
+ error.response.data.error.message) ||
372
+ error.message ||
373
+ "LLM request failed";
374
+
375
+ await logAuditEntry({
376
+ promptKey,
377
+ providerKey: provider.key,
378
+ model,
379
+ variables,
380
+ requestOptions: mergedOptions,
381
+ outcome: "failure",
382
+ errorMessage: message,
383
+ });
384
+
385
+ throw new Error(message);
386
+ }
387
+
388
+ return {
389
+ content: text,
390
+ model,
391
+ providerKey: provider.key,
392
+ usage,
393
+ raw: response && response.data ? response.data : null,
394
+ };
395
+ }
396
+
397
+ async function callAdhoc(
398
+ {
399
+ providerKey,
400
+ model,
401
+ messages,
402
+ promptKeyForAudit,
403
+ },
404
+ runtimeOptions = {},
405
+ ) {
406
+ const { providers: rawProviders } = await loadConfig();
407
+ const providers = normalizeProviderConfig(rawProviders);
408
+
409
+ const key = String(providerKey || "").trim();
410
+ let provider = providers[key];
411
+
412
+ console.log('[llm.service] callAdhoc providerKey:', key);
413
+ console.log('[llm.service] callAdhoc available provider keys:', Object.keys(providers));
414
+ console.log('[llm.service] callAdhoc found provider:', !!provider, provider ? { enabled: provider.enabled, hasApiKey: !!provider.apiKey } : null);
415
+
416
+ // Apply runtime overrides for provider if possible
417
+ if (runtimeOptions.apiKey || runtimeOptions.baseUrl) {
418
+ provider = {
419
+ ...(provider || { key: key || 'custom', enabled: true, baseUrl: 'https://openrouter.ai/api/v1' }),
420
+ ...(runtimeOptions.apiKey ? { apiKey: runtimeOptions.apiKey } : {}),
421
+ ...(runtimeOptions.baseUrl ? { baseUrl: runtimeOptions.baseUrl } : {}),
422
+ };
423
+ }
424
+
425
+ if (!provider || provider.enabled === false || !provider.apiKey) {
426
+ throw new Error("Provider not found, disabled, or missing apiKey");
427
+ }
428
+
429
+ const resolvedModel = String(model || runtimeOptions.model || provider.defaultModel || "google/gemini-2.5-flash-lite").trim();
430
+ if (!resolvedModel) {
431
+ throw new Error("Model is not configured");
432
+ }
433
+
434
+ const inputMessages = Array.isArray(messages) ? messages : [];
435
+ if (!inputMessages.length) {
436
+ throw new Error("messages is required");
437
+ }
438
+
439
+ const mergedOptions = {
440
+ ...(runtimeOptions || {}),
441
+ };
442
+
443
+ const url = computeCompletionURL(provider.baseUrl);
444
+
445
+ const body = {
446
+ model: resolvedModel,
447
+ messages: inputMessages,
448
+ };
449
+
450
+ const allowedOptions = [
451
+ "temperature",
452
+ "top_p",
453
+ "max_tokens",
454
+ "presence_penalty",
455
+ "frequency_penalty",
456
+ "stop",
457
+ "n",
458
+ "stream",
459
+ ];
460
+
461
+ for (const key of allowedOptions) {
462
+ if (mergedOptions[key] !== undefined) {
463
+ body[key] = mergedOptions[key];
464
+ }
465
+ }
466
+
467
+ let response;
468
+ let text = "";
469
+ let usage = null;
470
+
471
+ try {
472
+ // Debug: log curl equivalent
473
+ const curlHeaders = [
474
+ `-H "Authorization: Bearer ${provider.apiKey}"`,
475
+ `-H "Content-Type: application/json"`,
476
+ ...Object.entries(provider.extraHeaders || {}).map(([k, v]) => `-H "${k}: ${v}"`),
477
+ ].join(' ');
478
+ console.log(`[llm.service] adhoc curl equivalent:\n curl -X POST ${url} ${curlHeaders} -d '${JSON.stringify(body)}'\n`);
479
+
480
+ response = await axios.post(url, body, {
481
+ headers: {
482
+ Authorization: `Bearer ${provider.apiKey}`,
483
+ "Content-Type": "application/json",
484
+ ...provider.extraHeaders,
485
+ },
486
+ timeout: provider.timeoutMs,
487
+ });
488
+
489
+ const data = response && response.data ? response.data : {};
490
+ const choice =
491
+ Array.isArray(data.choices) && data.choices.length > 0
492
+ ? data.choices[0]
493
+ : null;
494
+ text =
495
+ choice && choice.message && typeof choice.message.content === "string"
496
+ ? choice.message.content
497
+ : "";
498
+
499
+ const rawUsage = data.usage || null;
500
+ const normalized = normalizeUsage(rawUsage);
501
+
502
+ if (normalized && normalized.cost === undefined) {
503
+ const pricing =
504
+ provider.modelPricing && typeof provider.modelPricing === "object"
505
+ ? provider.modelPricing[resolvedModel]
506
+ : null;
507
+ const computedCost = pricing
508
+ ? computeCostFromPricing(normalized, pricing)
509
+ : null;
510
+ if (computedCost !== null) {
511
+ normalized.cost = computedCost;
512
+ normalized.cost_source = "computed";
513
+ }
514
+ }
515
+
516
+ if (normalized && normalized.cost !== undefined && normalized.cost_source === undefined) {
517
+ normalized.cost_source = "provider";
518
+ }
519
+
520
+ usage = normalized;
521
+
522
+ await logAuditEntry({
523
+ promptKey: String(promptKeyForAudit || "adhoc"),
524
+ providerKey: provider.key,
525
+ model: resolvedModel,
526
+ variables: {},
527
+ requestOptions: mergedOptions,
528
+ outcome: "success",
529
+ usage,
530
+ });
531
+ } catch (error) {
532
+ const message =
533
+ (error.response && error.response.data && error.response.data.error &&
534
+ error.response.data.error.message) ||
535
+ error.message ||
536
+ "LLM request failed";
537
+
538
+ await logAuditEntry({
539
+ promptKey: String(promptKeyForAudit || "adhoc"),
540
+ providerKey: provider && provider.key,
541
+ model: resolvedModel,
542
+ variables: {},
543
+ requestOptions: mergedOptions || {},
544
+ outcome: "failure",
545
+ errorMessage: message,
546
+ });
547
+
548
+ throw new Error(message);
549
+ }
550
+
551
+ return {
552
+ content: text,
553
+ model: resolvedModel,
554
+ providerKey: provider.key,
555
+ usage,
556
+ raw: response && response.data ? response.data : null,
557
+ };
558
+ }
559
+
560
+ async function testPrompt(definition, variables = {}, runtimeOptions = {}) {
561
+ // Bypass cache to ensure we see latest settings
562
+ cache.ts = 0;
563
+ return call(definition.key, variables, runtimeOptions);
564
+ }
565
+
566
+ async function stream(promptKey, variables = {}, runtimeOptions = {}, { onToken } = {}) {
567
+ if (typeof onToken !== "function") {
568
+ throw new Error("onToken callback is required for streaming");
569
+ }
570
+
571
+ const { providers: rawProviders, prompts: rawPrompts } = await loadConfig();
572
+ const providers = normalizeProviderConfig(rawProviders);
573
+ const prompts = normalizePrompts(rawPrompts);
574
+
575
+ const prompt = prompts[promptKey];
576
+ if (!prompt || prompt.enabled === false) {
577
+ throw new Error("Prompt not found or disabled");
578
+ }
579
+
580
+ const provider = providers[prompt.providerKey];
581
+ if (!provider || provider.enabled === false || !provider.apiKey) {
582
+ throw new Error("Provider not found, disabled, or missing apiKey");
583
+ }
584
+
585
+ const mergedOptions = {
586
+ ...(prompt.defaultOptions || {}),
587
+ ...(runtimeOptions || {}),
588
+ stream: true,
589
+ };
590
+
591
+ const model =
592
+ mergedOptions.model || prompt.model || provider.defaultModel || "";
593
+ if (!model) {
594
+ throw new Error("Model is not configured");
595
+ }
596
+
597
+ const content = interpolateTemplate(prompt.template, variables);
598
+ const url = computeCompletionURL(provider.baseUrl);
599
+
600
+ const body = {
601
+ model,
602
+ stream: true,
603
+ messages: [
604
+ {
605
+ role: "user",
606
+ content,
607
+ },
608
+ ],
609
+ };
610
+
611
+ const allowedOptions = [
612
+ "temperature",
613
+ "top_p",
614
+ "max_tokens",
615
+ "presence_penalty",
616
+ "frequency_penalty",
617
+ "stop",
618
+ "n",
619
+ ];
620
+
621
+ for (const key of allowedOptions) {
622
+ if (mergedOptions[key] !== undefined) {
623
+ body[key] = mergedOptions[key];
624
+ }
625
+ }
626
+
627
+ let errorMessage = null;
628
+ let lastUsage = null;
629
+
630
+ try {
631
+ // Debug: log curl equivalent for streaming
632
+ const curlHeaders = [
633
+ `-H "Authorization: Bearer ${provider.apiKey}"`,
634
+ `-H "Content-Type: application/json"`,
635
+ ...Object.entries(provider.extraHeaders || {}).map(([k, v]) => `-H "${k}: ${v}"`),
636
+ ].join(' ');
637
+ console.log(`[llm.service] streaming curl equivalent:\n curl -X POST ${url} ${curlHeaders} -d '${JSON.stringify(body)}'\n`);
638
+
639
+ const response = await axios.post(url, body, {
640
+ headers: {
641
+ Authorization: `Bearer ${provider.apiKey}`,
642
+ "Content-Type": "application/json",
643
+ ...provider.extraHeaders,
644
+ },
645
+ timeout: provider.timeoutMs,
646
+ responseType: "stream",
647
+ });
648
+
649
+ await new Promise((resolve, reject) => {
650
+ let buffer = "";
651
+ response.data.on("data", (chunk) => {
652
+ buffer += chunk.toString("utf8");
653
+
654
+ const lines = buffer.split(/\r?\n/);
655
+ buffer = lines.pop() || "";
656
+
657
+ for (const line of lines) {
658
+ const trimmed = line.trim();
659
+ if (!trimmed || !trimmed.startsWith("data:")) continue;
660
+ const payload = trimmed.slice(5).trim();
661
+ if (payload === "[DONE]") {
662
+ continue;
663
+ }
664
+ try {
665
+ const parsed = JSON.parse(payload);
666
+
667
+ if (parsed && parsed.usage && typeof parsed.usage === "object") {
668
+ lastUsage = parsed.usage;
669
+ }
670
+
671
+ const choice =
672
+ Array.isArray(parsed.choices) && parsed.choices.length > 0
673
+ ? parsed.choices[0]
674
+ : null;
675
+ const delta = choice && choice.delta ? choice.delta : {};
676
+ const text = typeof delta.content === "string" ? delta.content : "";
677
+ if (text) {
678
+ onToken(text, parsed);
679
+ }
680
+ } catch (e) {
681
+ // Ignore parse errors for individual chunks
682
+ }
683
+ }
684
+ });
685
+
686
+ response.data.on("end", () => resolve());
687
+ response.data.on("error", (err) => {
688
+ errorMessage = err.message || "Stream error";
689
+ reject(err);
690
+ });
691
+ });
692
+
693
+ await logAuditEntry({
694
+ promptKey,
695
+ providerKey: provider.key,
696
+ model,
697
+ variables,
698
+ requestOptions: mergedOptions,
699
+ outcome: "success",
700
+ usage: (() => {
701
+ const normalized = normalizeUsage(lastUsage);
702
+ if (!normalized) return null;
703
+ if (normalized.cost === undefined) {
704
+ const pricing =
705
+ provider.modelPricing && typeof provider.modelPricing === "object"
706
+ ? provider.modelPricing[model]
707
+ : null;
708
+ const computedCost = pricing
709
+ ? computeCostFromPricing(normalized, pricing)
710
+ : null;
711
+ if (computedCost !== null) {
712
+ normalized.cost = computedCost;
713
+ normalized.cost_source = "computed";
714
+ }
715
+ }
716
+ if (normalized.cost !== undefined && normalized.cost_source === undefined) {
717
+ normalized.cost_source = "provider";
718
+ }
719
+ return normalized;
720
+ })(),
721
+ });
722
+ } catch (error) {
723
+ const message =
724
+ (error.response && error.response.data && error.response.data.error &&
725
+ error.response.data.error.message) ||
726
+ error.message ||
727
+ errorMessage ||
728
+ "LLM streaming request failed";
729
+
730
+ await logAuditEntry({
731
+ promptKey,
732
+ providerKey: provider && provider.key,
733
+ model,
734
+ variables,
735
+ requestOptions: mergedOptions || {},
736
+ outcome: "failure",
737
+ errorMessage: message,
738
+ });
739
+
740
+ throw new Error(message);
741
+ }
742
+ }
743
+
744
+ module.exports = {
745
+ call,
746
+ testPrompt,
747
+ callAdhoc,
748
+ stream,
749
+ };