@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.
- package/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- 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
|
+
};
|