@actuate-media/cms-core 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/ai/ai.test.d.ts +2 -0
- package/dist/__tests__/ai/ai.test.d.ts.map +1 -0
- package/dist/__tests__/ai/ai.test.js +261 -0
- package/dist/__tests__/ai/ai.test.js.map +1 -0
- package/dist/ai/catalog.d.ts +29 -0
- package/dist/ai/catalog.d.ts.map +1 -0
- package/dist/ai/catalog.js +340 -0
- package/dist/ai/catalog.js.map +1 -0
- package/dist/ai/config-store.d.ts +55 -0
- package/dist/ai/config-store.d.ts.map +1 -0
- package/dist/ai/config-store.js +177 -0
- package/dist/ai/config-store.js.map +1 -0
- package/dist/ai/feature-defaults.d.ts +20 -0
- package/dist/ai/feature-defaults.d.ts.map +1 -0
- package/dist/ai/feature-defaults.js +104 -0
- package/dist/ai/feature-defaults.js.map +1 -0
- package/dist/ai/index.d.ts +13 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +13 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/models.d.ts +37 -0
- package/dist/ai/models.d.ts.map +1 -0
- package/dist/ai/models.js +126 -0
- package/dist/ai/models.js.map +1 -0
- package/dist/ai/providers/anthropic.d.ts +9 -0
- package/dist/ai/providers/anthropic.d.ts.map +1 -0
- package/dist/ai/providers/anthropic.js +36 -0
- package/dist/ai/providers/anthropic.js.map +1 -0
- package/dist/ai/providers/custom.d.ts +11 -0
- package/dist/ai/providers/custom.d.ts.map +1 -0
- package/dist/ai/providers/custom.js +70 -0
- package/dist/ai/providers/custom.js.map +1 -0
- package/dist/ai/providers/http.d.ts +32 -0
- package/dist/ai/providers/http.d.ts.map +1 -0
- package/dist/ai/providers/http.js +90 -0
- package/dist/ai/providers/http.js.map +1 -0
- package/dist/ai/providers/index.d.ts +16 -0
- package/dist/ai/providers/index.d.ts.map +1 -0
- package/dist/ai/providers/index.js +25 -0
- package/dist/ai/providers/index.js.map +1 -0
- package/dist/ai/providers/openai.d.ts +10 -0
- package/dist/ai/providers/openai.d.ts.map +1 -0
- package/dist/ai/providers/openai.js +41 -0
- package/dist/ai/providers/openai.js.map +1 -0
- package/dist/ai/providers/xai.d.ts +9 -0
- package/dist/ai/providers/xai.d.ts.map +1 -0
- package/dist/ai/providers/xai.js +35 -0
- package/dist/ai/providers/xai.js.map +1 -0
- package/dist/ai/types.d.ts +161 -0
- package/dist/ai/types.d.ts.map +1 -0
- package/dist/ai/types.js +11 -0
- package/dist/ai/types.js.map +1 -0
- package/dist/ai/usage.d.ts +18 -0
- package/dist/ai/usage.d.ts.map +1 -0
- package/dist/ai/usage.js +70 -0
- package/dist/ai/usage.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +477 -0
- package/dist/api/handlers.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../../src/ai/usage.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAgBhD;;;GAGG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,CAAC;IAAC,GAAG,CAAC,EAAE,IAAI,CAAA;CAAE,GACpC,OAAO,CAAC,cAAc,CAAC,CAiDzB"}
|
package/dist/ai/usage.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI usage aggregation.
|
|
3
|
+
*
|
|
4
|
+
* Until the dedicated per-run metering table lands (Phase 4), usage is derived
|
|
5
|
+
* from token totals already recorded in `actuate_audit_logs` (`details
|
|
6
|
+
* .totalTokensUsed` / `details.tokensUsed`). This returns REAL counts — it is 0
|
|
7
|
+
* when nothing has run, never a fabricated number.
|
|
8
|
+
*/
|
|
9
|
+
import { getAIConfig } from './config-store.js';
|
|
10
|
+
/** First instant of the current calendar month (UTC). */
|
|
11
|
+
function monthStart(now = new Date()) {
|
|
12
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
|
|
13
|
+
}
|
|
14
|
+
/** First instant of the next calendar month (UTC). */
|
|
15
|
+
function nextMonthStart(now = new Date()) {
|
|
16
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0));
|
|
17
|
+
}
|
|
18
|
+
function num(value) {
|
|
19
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Sum token usage from audit-log entries within the period. Recognizes the
|
|
23
|
+
* fields current AI handlers write today and is tolerant of missing data.
|
|
24
|
+
*/
|
|
25
|
+
export async function getUsageSummary(db, period) {
|
|
26
|
+
const start = period?.start ?? monthStart();
|
|
27
|
+
const end = period?.end ?? nextMonthStart();
|
|
28
|
+
const config = await getAIConfig(db);
|
|
29
|
+
const monthlyTokenLimit = config.settings.budget.monthlyTokenLimit;
|
|
30
|
+
const base = {
|
|
31
|
+
totalTokens: 0,
|
|
32
|
+
promptTokens: 0,
|
|
33
|
+
completionTokens: 0,
|
|
34
|
+
monthlyTokenLimit,
|
|
35
|
+
periodStart: start.toISOString(),
|
|
36
|
+
periodEnd: end.toISOString(),
|
|
37
|
+
runCount: 0,
|
|
38
|
+
};
|
|
39
|
+
const d = db;
|
|
40
|
+
if (!d?.auditLog?.findMany)
|
|
41
|
+
return base;
|
|
42
|
+
let rows = [];
|
|
43
|
+
try {
|
|
44
|
+
rows = await d.auditLog.findMany({
|
|
45
|
+
where: { createdAt: { gte: start, lt: end } },
|
|
46
|
+
select: { details: true },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return base;
|
|
51
|
+
}
|
|
52
|
+
let totalTokens = 0;
|
|
53
|
+
let promptTokens = 0;
|
|
54
|
+
let completionTokens = 0;
|
|
55
|
+
let runCount = 0;
|
|
56
|
+
for (const row of rows) {
|
|
57
|
+
const details = (row?.details ?? {});
|
|
58
|
+
const total = num(details.totalTokensUsed) + num(details.tokensUsed) + num(details.totalTokens);
|
|
59
|
+
const prompt = num(details.promptTokens);
|
|
60
|
+
const completion = num(details.completionTokens);
|
|
61
|
+
if (total > 0 || prompt > 0 || completion > 0) {
|
|
62
|
+
runCount += 1;
|
|
63
|
+
totalTokens += total > 0 ? total : prompt + completion;
|
|
64
|
+
promptTokens += prompt;
|
|
65
|
+
completionTokens += completion;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ...base, totalTokens, promptTokens, completionTokens, runCount };
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=usage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usage.js","sourceRoot":"","sources":["../../src/ai/usage.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAwhC5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAiVD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAqiYzD"}
|
package/dist/api/handlers.js
CHANGED
|
@@ -10073,6 +10073,483 @@ export function registerCMSRoutes(router) {
|
|
|
10073
10073
|
return internalError(err, 'ai status');
|
|
10074
10074
|
}
|
|
10075
10075
|
});
|
|
10076
|
+
// ---------------------------------------------------------------------------
|
|
10077
|
+
// Settings > AI — provider connections, dynamic model catalog, feature flags.
|
|
10078
|
+
// Backed by the `__ai_config__` config document (see `../ai/config-store.ts`).
|
|
10079
|
+
// ADMIN-gated. Provider API keys are encrypted at rest and NEVER returned.
|
|
10080
|
+
// ---------------------------------------------------------------------------
|
|
10081
|
+
const AI_PROVIDER_KEYS = ['openai', 'anthropic', 'xai', 'custom'];
|
|
10082
|
+
function isAiProviderKey(value) {
|
|
10083
|
+
return typeof value === 'string' && AI_PROVIDER_KEYS.includes(value);
|
|
10084
|
+
}
|
|
10085
|
+
function defaultProviderName(provider) {
|
|
10086
|
+
switch (provider) {
|
|
10087
|
+
case 'openai':
|
|
10088
|
+
return 'OpenAI';
|
|
10089
|
+
case 'anthropic':
|
|
10090
|
+
return 'Anthropic';
|
|
10091
|
+
case 'xai':
|
|
10092
|
+
return 'xAI (Grok)';
|
|
10093
|
+
default:
|
|
10094
|
+
return 'Custom provider';
|
|
10095
|
+
}
|
|
10096
|
+
}
|
|
10097
|
+
router.get('/ai/settings', async (request) => {
|
|
10098
|
+
try {
|
|
10099
|
+
const auth = await requireAuth(request);
|
|
10100
|
+
if (auth.error)
|
|
10101
|
+
return auth.error;
|
|
10102
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10103
|
+
if (adminErr)
|
|
10104
|
+
return adminErr;
|
|
10105
|
+
const { getAIConfig, toPublicConfig, getModels, resolveModelOrUnavailable } = await import('../ai/index.js');
|
|
10106
|
+
const config = await getAIConfig(db());
|
|
10107
|
+
const pub = toPublicConfig(config);
|
|
10108
|
+
const catalog = await getModels(db());
|
|
10109
|
+
const defaultModel = resolveModelOrUnavailable(config.settings.defaultModelId, catalog);
|
|
10110
|
+
return json({
|
|
10111
|
+
data: {
|
|
10112
|
+
settings: pub.settings,
|
|
10113
|
+
providers: pub.providers,
|
|
10114
|
+
defaultModel,
|
|
10115
|
+
updatedAt: config.updatedAt ?? null,
|
|
10116
|
+
updatedBy: config.updatedBy ?? null,
|
|
10117
|
+
},
|
|
10118
|
+
});
|
|
10119
|
+
}
|
|
10120
|
+
catch (err) {
|
|
10121
|
+
return internalError(err, 'ai/settings GET');
|
|
10122
|
+
}
|
|
10123
|
+
});
|
|
10124
|
+
router.put('/ai/settings', async (request) => {
|
|
10125
|
+
try {
|
|
10126
|
+
const auth = await requireAuth(request);
|
|
10127
|
+
if (auth.error)
|
|
10128
|
+
return auth.error;
|
|
10129
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10130
|
+
if (adminErr)
|
|
10131
|
+
return adminErr;
|
|
10132
|
+
const body = (await request.json().catch(() => null));
|
|
10133
|
+
if (!body || typeof body !== 'object') {
|
|
10134
|
+
return errorResponse('Request body must be an object', 400);
|
|
10135
|
+
}
|
|
10136
|
+
const { getAIConfig, putAIConfig, getModels } = await import('../ai/index.js');
|
|
10137
|
+
const config = await getAIConfig(db());
|
|
10138
|
+
const catalog = await getModels(db());
|
|
10139
|
+
// Validate the selected default provider exists.
|
|
10140
|
+
if (body.defaultProviderId &&
|
|
10141
|
+
!config.providers.some((p) => p.id === body.defaultProviderId)) {
|
|
10142
|
+
return errorResponse('Selected provider is not connected', 400);
|
|
10143
|
+
}
|
|
10144
|
+
// Validate the selected default model is in the catalog and available.
|
|
10145
|
+
if (body.defaultModelId) {
|
|
10146
|
+
const model = catalog.find((m) => m.id === body.defaultModelId || m.providerModelId === body.defaultModelId);
|
|
10147
|
+
if (!model)
|
|
10148
|
+
return errorResponse('Selected model is not in the catalog', 400);
|
|
10149
|
+
if (model.status === 'unavailable') {
|
|
10150
|
+
return errorResponse('Selected model is currently unavailable', 400);
|
|
10151
|
+
}
|
|
10152
|
+
}
|
|
10153
|
+
// Validate budget.
|
|
10154
|
+
let monthlyTokenLimit = config.settings.budget.monthlyTokenLimit;
|
|
10155
|
+
if (body.budget && 'monthlyTokenLimit' in body.budget) {
|
|
10156
|
+
const v = body.budget.monthlyTokenLimit;
|
|
10157
|
+
if (v === null)
|
|
10158
|
+
monthlyTokenLimit = undefined;
|
|
10159
|
+
else if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
|
|
10160
|
+
monthlyTokenLimit = v;
|
|
10161
|
+
else
|
|
10162
|
+
return errorResponse('monthlyTokenLimit must be a non-negative number or null', 400);
|
|
10163
|
+
}
|
|
10164
|
+
// Apply feature toggles by key (ignore unknown keys).
|
|
10165
|
+
const toggles = new Map((body.features ?? []).map((f) => [f.key, Boolean(f.enabled)]));
|
|
10166
|
+
const nextFeatures = config.settings.features.map((f) => toggles.has(f.key) ? { ...f, enabled: toggles.get(f.key) } : f);
|
|
10167
|
+
const changedFeatures = config.settings.features
|
|
10168
|
+
.filter((f) => toggles.has(f.key) && toggles.get(f.key) !== f.enabled)
|
|
10169
|
+
.map((f) => ({ key: f.key, from: f.enabled, to: toggles.get(f.key) }));
|
|
10170
|
+
const nextSettings = {
|
|
10171
|
+
...config.settings,
|
|
10172
|
+
defaultProviderId: body.defaultProviderId === null
|
|
10173
|
+
? undefined
|
|
10174
|
+
: (body.defaultProviderId ?? config.settings.defaultProviderId),
|
|
10175
|
+
defaultModelId: body.defaultModelId === null
|
|
10176
|
+
? undefined
|
|
10177
|
+
: (body.defaultModelId ?? config.settings.defaultModelId),
|
|
10178
|
+
features: nextFeatures,
|
|
10179
|
+
budget: { ...config.settings.budget, monthlyTokenLimit },
|
|
10180
|
+
};
|
|
10181
|
+
const saved = await putAIConfig(db(), { ...config, settings: nextSettings }, auth.session.userId);
|
|
10182
|
+
try {
|
|
10183
|
+
await logEvent({
|
|
10184
|
+
event: 'update_ai_settings',
|
|
10185
|
+
userId: auth.session.userId,
|
|
10186
|
+
details: {
|
|
10187
|
+
defaultProviderId: nextSettings.defaultProviderId ?? null,
|
|
10188
|
+
defaultModelId: nextSettings.defaultModelId ?? null,
|
|
10189
|
+
monthlyTokenLimit: monthlyTokenLimit ?? null,
|
|
10190
|
+
changedFeatures,
|
|
10191
|
+
source: 'settings.ai',
|
|
10192
|
+
},
|
|
10193
|
+
});
|
|
10194
|
+
}
|
|
10195
|
+
catch {
|
|
10196
|
+
// Audit must never block the write.
|
|
10197
|
+
}
|
|
10198
|
+
const { toPublicConfig } = await import('../ai/index.js');
|
|
10199
|
+
return json({ data: toPublicConfig(saved) });
|
|
10200
|
+
}
|
|
10201
|
+
catch (err) {
|
|
10202
|
+
return internalError(err, 'ai/settings PUT');
|
|
10203
|
+
}
|
|
10204
|
+
});
|
|
10205
|
+
router.post('/ai/providers', async (request) => {
|
|
10206
|
+
try {
|
|
10207
|
+
const auth = await requireAuth(request);
|
|
10208
|
+
if (auth.error)
|
|
10209
|
+
return auth.error;
|
|
10210
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10211
|
+
if (adminErr)
|
|
10212
|
+
return adminErr;
|
|
10213
|
+
const body = (await request.json().catch(() => null));
|
|
10214
|
+
if (!body || !isAiProviderKey(body.provider)) {
|
|
10215
|
+
return errorResponse('A valid `provider` is required', 400);
|
|
10216
|
+
}
|
|
10217
|
+
const provider = body.provider;
|
|
10218
|
+
if (provider === 'custom') {
|
|
10219
|
+
if (!body.baseUrl || typeof body.baseUrl !== 'string') {
|
|
10220
|
+
return errorResponse('A `baseUrl` is required for a custom provider', 400);
|
|
10221
|
+
}
|
|
10222
|
+
const { validateWebhookUrl } = await import('../security/webhook.js');
|
|
10223
|
+
const check = validateWebhookUrl(body.baseUrl);
|
|
10224
|
+
if (!check.valid)
|
|
10225
|
+
return errorResponse(`Invalid baseUrl: ${check.error ?? 'unsafe'}`, 400);
|
|
10226
|
+
}
|
|
10227
|
+
const { getAIConfig, putAIConfig, encryptProviderKey, getEnvApiKey, resolveProviderKey, toPublicProvider, } = await import('../ai/index.js');
|
|
10228
|
+
const { testConnection } = await import('../ai/providers/index.js');
|
|
10229
|
+
const config = await getAIConfig(db());
|
|
10230
|
+
const now = new Date().toISOString();
|
|
10231
|
+
const envManaged = Boolean(getEnvApiKey(provider));
|
|
10232
|
+
let apiKeyEncrypted;
|
|
10233
|
+
let apiKeyLast4;
|
|
10234
|
+
if (!envManaged && typeof body.apiKey === 'string' && body.apiKey.trim()) {
|
|
10235
|
+
const enc = await encryptProviderKey(body.apiKey.trim());
|
|
10236
|
+
apiKeyEncrypted = enc.apiKeyEncrypted;
|
|
10237
|
+
apiKeyLast4 = enc.apiKeyLast4;
|
|
10238
|
+
}
|
|
10239
|
+
const connection = {
|
|
10240
|
+
id: crypto.randomUUID(),
|
|
10241
|
+
provider,
|
|
10242
|
+
displayName: (typeof body.displayName === 'string' && body.displayName.trim()) ||
|
|
10243
|
+
defaultProviderName(provider),
|
|
10244
|
+
enabled: body.enabled !== false,
|
|
10245
|
+
status: 'unknown',
|
|
10246
|
+
apiKeyEncrypted,
|
|
10247
|
+
apiKeyLast4,
|
|
10248
|
+
baseUrl: provider === 'custom' ? body.baseUrl : undefined,
|
|
10249
|
+
organizationId: body.organizationId?.trim() || undefined,
|
|
10250
|
+
projectId: body.projectId?.trim() || undefined,
|
|
10251
|
+
lastTestedAt: undefined,
|
|
10252
|
+
createdAt: now,
|
|
10253
|
+
updatedAt: now,
|
|
10254
|
+
updatedBy: auth.session.userId,
|
|
10255
|
+
};
|
|
10256
|
+
// Optional test-before-save.
|
|
10257
|
+
if (body.test) {
|
|
10258
|
+
const key = await resolveProviderKey(connection);
|
|
10259
|
+
const result = await testConnection(provider, {
|
|
10260
|
+
apiKey: key ?? '',
|
|
10261
|
+
baseUrl: connection.baseUrl,
|
|
10262
|
+
organizationId: connection.organizationId,
|
|
10263
|
+
projectId: connection.projectId,
|
|
10264
|
+
});
|
|
10265
|
+
connection.status = result.status;
|
|
10266
|
+
connection.lastTestedAt = result.testedAt;
|
|
10267
|
+
}
|
|
10268
|
+
const saved = await putAIConfig(db(), { ...config, providers: [...config.providers, connection] }, auth.session.userId);
|
|
10269
|
+
try {
|
|
10270
|
+
await logEvent({
|
|
10271
|
+
event: 'create_ai_provider',
|
|
10272
|
+
userId: auth.session.userId,
|
|
10273
|
+
details: {
|
|
10274
|
+
providerId: connection.id,
|
|
10275
|
+
provider,
|
|
10276
|
+
envManaged,
|
|
10277
|
+
tested: Boolean(body.test),
|
|
10278
|
+
status: connection.status,
|
|
10279
|
+
source: 'settings.ai',
|
|
10280
|
+
},
|
|
10281
|
+
});
|
|
10282
|
+
}
|
|
10283
|
+
catch {
|
|
10284
|
+
/* never block on audit */
|
|
10285
|
+
}
|
|
10286
|
+
const created = saved.providers.find((p) => p.id === connection.id);
|
|
10287
|
+
return json({ data: toPublicProvider(created) }, 201);
|
|
10288
|
+
}
|
|
10289
|
+
catch (err) {
|
|
10290
|
+
return internalError(err, 'ai/providers POST');
|
|
10291
|
+
}
|
|
10292
|
+
});
|
|
10293
|
+
router.put('/ai/providers/:id', async (request, params) => {
|
|
10294
|
+
try {
|
|
10295
|
+
const auth = await requireAuth(request);
|
|
10296
|
+
if (auth.error)
|
|
10297
|
+
return auth.error;
|
|
10298
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10299
|
+
if (adminErr)
|
|
10300
|
+
return adminErr;
|
|
10301
|
+
const body = (await request.json().catch(() => null));
|
|
10302
|
+
if (!body || typeof body !== 'object') {
|
|
10303
|
+
return errorResponse('Request body must be an object', 400);
|
|
10304
|
+
}
|
|
10305
|
+
const { getAIConfig, putAIConfig, encryptProviderKey, getEnvApiKey, toPublicProvider } = await import('../ai/index.js');
|
|
10306
|
+
const config = await getAIConfig(db());
|
|
10307
|
+
const idx = config.providers.findIndex((p) => p.id === params.id);
|
|
10308
|
+
if (idx === -1)
|
|
10309
|
+
return errorResponse('Provider connection not found', 404);
|
|
10310
|
+
const prev = config.providers[idx];
|
|
10311
|
+
if (prev.provider === 'custom' && typeof body.baseUrl === 'string') {
|
|
10312
|
+
const { validateWebhookUrl } = await import('../security/webhook.js');
|
|
10313
|
+
const check = validateWebhookUrl(body.baseUrl);
|
|
10314
|
+
if (!check.valid)
|
|
10315
|
+
return errorResponse(`Invalid baseUrl: ${check.error ?? 'unsafe'}`, 400);
|
|
10316
|
+
}
|
|
10317
|
+
const envManaged = Boolean(getEnvApiKey(prev.provider));
|
|
10318
|
+
let { apiKeyEncrypted, apiKeyLast4 } = prev;
|
|
10319
|
+
let keyChanged = false;
|
|
10320
|
+
if (!envManaged && body.apiKey !== undefined) {
|
|
10321
|
+
if (body.apiKey === null || body.apiKey === '') {
|
|
10322
|
+
apiKeyEncrypted = undefined;
|
|
10323
|
+
apiKeyLast4 = undefined;
|
|
10324
|
+
keyChanged = true;
|
|
10325
|
+
}
|
|
10326
|
+
else if (typeof body.apiKey === 'string' && body.apiKey.trim()) {
|
|
10327
|
+
const enc = await encryptProviderKey(body.apiKey.trim());
|
|
10328
|
+
apiKeyEncrypted = enc.apiKeyEncrypted;
|
|
10329
|
+
apiKeyLast4 = enc.apiKeyLast4;
|
|
10330
|
+
keyChanged = true;
|
|
10331
|
+
}
|
|
10332
|
+
}
|
|
10333
|
+
const updated = {
|
|
10334
|
+
...prev,
|
|
10335
|
+
displayName: typeof body.displayName === 'string' && body.displayName.trim()
|
|
10336
|
+
? body.displayName.trim()
|
|
10337
|
+
: prev.displayName,
|
|
10338
|
+
enabled: typeof body.enabled === 'boolean' ? body.enabled : prev.enabled,
|
|
10339
|
+
baseUrl: prev.provider === 'custom' && typeof body.baseUrl === 'string'
|
|
10340
|
+
? body.baseUrl
|
|
10341
|
+
: prev.baseUrl,
|
|
10342
|
+
organizationId: body.organizationId === null
|
|
10343
|
+
? undefined
|
|
10344
|
+
: (body.organizationId?.trim() ?? prev.organizationId),
|
|
10345
|
+
projectId: body.projectId === null ? undefined : (body.projectId?.trim() ?? prev.projectId),
|
|
10346
|
+
apiKeyEncrypted,
|
|
10347
|
+
apiKeyLast4,
|
|
10348
|
+
// A new key invalidates the previous test result.
|
|
10349
|
+
status: keyChanged ? 'unknown' : prev.status,
|
|
10350
|
+
lastTestedAt: keyChanged ? undefined : prev.lastTestedAt,
|
|
10351
|
+
updatedAt: new Date().toISOString(),
|
|
10352
|
+
updatedBy: auth.session.userId,
|
|
10353
|
+
};
|
|
10354
|
+
const nextProviders = [...config.providers];
|
|
10355
|
+
nextProviders[idx] = updated;
|
|
10356
|
+
const saved = await putAIConfig(db(), { ...config, providers: nextProviders }, auth.session.userId);
|
|
10357
|
+
try {
|
|
10358
|
+
await logEvent({
|
|
10359
|
+
event: 'update_ai_provider',
|
|
10360
|
+
userId: auth.session.userId,
|
|
10361
|
+
details: {
|
|
10362
|
+
providerId: prev.id,
|
|
10363
|
+
provider: prev.provider,
|
|
10364
|
+
keyChanged,
|
|
10365
|
+
source: 'settings.ai',
|
|
10366
|
+
},
|
|
10367
|
+
});
|
|
10368
|
+
}
|
|
10369
|
+
catch {
|
|
10370
|
+
/* never block on audit */
|
|
10371
|
+
}
|
|
10372
|
+
const out = saved.providers.find((p) => p.id === prev.id);
|
|
10373
|
+
return json({ data: toPublicProvider(out) });
|
|
10374
|
+
}
|
|
10375
|
+
catch (err) {
|
|
10376
|
+
return internalError(err, 'ai/providers PUT');
|
|
10377
|
+
}
|
|
10378
|
+
});
|
|
10379
|
+
router.delete('/ai/providers/:id', async (request, params) => {
|
|
10380
|
+
try {
|
|
10381
|
+
const auth = await requireAuth(request);
|
|
10382
|
+
if (auth.error)
|
|
10383
|
+
return auth.error;
|
|
10384
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10385
|
+
if (adminErr)
|
|
10386
|
+
return adminErr;
|
|
10387
|
+
const id = params.id;
|
|
10388
|
+
if (!id)
|
|
10389
|
+
return errorResponse('Provider connection not found', 404);
|
|
10390
|
+
const { getAIConfig, putAIConfig } = await import('../ai/index.js');
|
|
10391
|
+
const config = await getAIConfig(db());
|
|
10392
|
+
const exists = config.providers.some((p) => p.id === id);
|
|
10393
|
+
if (!exists)
|
|
10394
|
+
return errorResponse('Provider connection not found', 404);
|
|
10395
|
+
const nextCatalog = { ...(config.catalog ?? {}) };
|
|
10396
|
+
delete nextCatalog[id];
|
|
10397
|
+
// Clear default selectors that pointed at the removed provider.
|
|
10398
|
+
const clearedProvider = config.settings.defaultProviderId === id;
|
|
10399
|
+
const nextSettings = clearedProvider
|
|
10400
|
+
? { ...config.settings, defaultProviderId: undefined, defaultModelId: undefined }
|
|
10401
|
+
: config.settings;
|
|
10402
|
+
await putAIConfig(db(), {
|
|
10403
|
+
...config,
|
|
10404
|
+
providers: config.providers.filter((p) => p.id !== id),
|
|
10405
|
+
catalog: nextCatalog,
|
|
10406
|
+
settings: nextSettings,
|
|
10407
|
+
}, auth.session.userId);
|
|
10408
|
+
try {
|
|
10409
|
+
await logEvent({
|
|
10410
|
+
event: 'delete_ai_provider',
|
|
10411
|
+
userId: auth.session.userId,
|
|
10412
|
+
details: { providerId: id, source: 'settings.ai' },
|
|
10413
|
+
});
|
|
10414
|
+
}
|
|
10415
|
+
catch {
|
|
10416
|
+
/* never block on audit */
|
|
10417
|
+
}
|
|
10418
|
+
return json({ data: { id: params.id, deleted: true } });
|
|
10419
|
+
}
|
|
10420
|
+
catch (err) {
|
|
10421
|
+
return internalError(err, 'ai/providers DELETE');
|
|
10422
|
+
}
|
|
10423
|
+
});
|
|
10424
|
+
router.post('/ai/providers/:id/test', async (request, params) => {
|
|
10425
|
+
try {
|
|
10426
|
+
const auth = await requireAuth(request);
|
|
10427
|
+
if (auth.error)
|
|
10428
|
+
return auth.error;
|
|
10429
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10430
|
+
if (adminErr)
|
|
10431
|
+
return adminErr;
|
|
10432
|
+
const { getAIConfig, putAIConfig, resolveProviderKey, toPublicProvider } = await import('../ai/index.js');
|
|
10433
|
+
const { testConnection } = await import('../ai/providers/index.js');
|
|
10434
|
+
const config = await getAIConfig(db());
|
|
10435
|
+
const idx = config.providers.findIndex((p) => p.id === params.id);
|
|
10436
|
+
if (idx === -1)
|
|
10437
|
+
return errorResponse('Provider connection not found', 404);
|
|
10438
|
+
const conn = config.providers[idx];
|
|
10439
|
+
const key = await resolveProviderKey(conn);
|
|
10440
|
+
const result = await testConnection(conn.provider, {
|
|
10441
|
+
apiKey: key ?? '',
|
|
10442
|
+
baseUrl: conn.baseUrl,
|
|
10443
|
+
organizationId: conn.organizationId,
|
|
10444
|
+
projectId: conn.projectId,
|
|
10445
|
+
});
|
|
10446
|
+
const updated = { ...conn, status: result.status, lastTestedAt: result.testedAt };
|
|
10447
|
+
const nextProviders = [...config.providers];
|
|
10448
|
+
nextProviders[idx] = updated;
|
|
10449
|
+
const saved = await putAIConfig(db(), { ...config, providers: nextProviders }, auth.session.userId);
|
|
10450
|
+
try {
|
|
10451
|
+
await logEvent({
|
|
10452
|
+
event: 'test_ai_provider',
|
|
10453
|
+
userId: auth.session.userId,
|
|
10454
|
+
details: {
|
|
10455
|
+
providerId: conn.id,
|
|
10456
|
+
provider: conn.provider,
|
|
10457
|
+
status: result.status,
|
|
10458
|
+
source: 'settings.ai',
|
|
10459
|
+
},
|
|
10460
|
+
});
|
|
10461
|
+
}
|
|
10462
|
+
catch {
|
|
10463
|
+
/* never block on audit */
|
|
10464
|
+
}
|
|
10465
|
+
const out = saved.providers.find((p) => p.id === conn.id);
|
|
10466
|
+
return json({ data: { provider: toPublicProvider(out), result } });
|
|
10467
|
+
}
|
|
10468
|
+
catch (err) {
|
|
10469
|
+
return internalError(err, 'ai/providers test');
|
|
10470
|
+
}
|
|
10471
|
+
});
|
|
10472
|
+
router.post('/ai/providers/:id/sync-models', async (request, params) => {
|
|
10473
|
+
try {
|
|
10474
|
+
const auth = await requireAuth(request);
|
|
10475
|
+
if (auth.error)
|
|
10476
|
+
return auth.error;
|
|
10477
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10478
|
+
if (adminErr)
|
|
10479
|
+
return adminErr;
|
|
10480
|
+
const id = params.id;
|
|
10481
|
+
if (!id)
|
|
10482
|
+
return errorResponse('Provider connection not found', 404);
|
|
10483
|
+
const { syncModels } = await import('../ai/index.js');
|
|
10484
|
+
const result = await syncModels(db(), id).catch((err) => {
|
|
10485
|
+
if (err instanceof Error && /not found/i.test(err.message))
|
|
10486
|
+
return null;
|
|
10487
|
+
throw err;
|
|
10488
|
+
});
|
|
10489
|
+
if (!result)
|
|
10490
|
+
return errorResponse('Provider connection not found', 404);
|
|
10491
|
+
try {
|
|
10492
|
+
await logEvent({
|
|
10493
|
+
event: 'sync_ai_models',
|
|
10494
|
+
userId: auth.session.userId,
|
|
10495
|
+
details: {
|
|
10496
|
+
providerId: id,
|
|
10497
|
+
modelCount: result.models.length,
|
|
10498
|
+
fromFallback: result.fromFallback,
|
|
10499
|
+
source: 'settings.ai',
|
|
10500
|
+
},
|
|
10501
|
+
});
|
|
10502
|
+
}
|
|
10503
|
+
catch {
|
|
10504
|
+
/* never block on audit */
|
|
10505
|
+
}
|
|
10506
|
+
return json({
|
|
10507
|
+
data: {
|
|
10508
|
+
models: result.models,
|
|
10509
|
+
fromFallback: result.fromFallback,
|
|
10510
|
+
warning: result.warning ?? null,
|
|
10511
|
+
},
|
|
10512
|
+
});
|
|
10513
|
+
}
|
|
10514
|
+
catch (err) {
|
|
10515
|
+
return internalError(err, 'ai/providers sync-models');
|
|
10516
|
+
}
|
|
10517
|
+
});
|
|
10518
|
+
router.get('/ai/models', async (request) => {
|
|
10519
|
+
try {
|
|
10520
|
+
const auth = await requireAuth(request);
|
|
10521
|
+
if (auth.error)
|
|
10522
|
+
return auth.error;
|
|
10523
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10524
|
+
if (adminErr)
|
|
10525
|
+
return adminErr;
|
|
10526
|
+
const url = new URL(request.url);
|
|
10527
|
+
const connectionId = url.searchParams.get('connectionId') ?? undefined;
|
|
10528
|
+
const provider = url.searchParams.get('provider') ?? undefined;
|
|
10529
|
+
const { getModels } = await import('../ai/index.js');
|
|
10530
|
+
const models = await getModels(db(), { connectionId, provider });
|
|
10531
|
+
return json({ data: { models } });
|
|
10532
|
+
}
|
|
10533
|
+
catch (err) {
|
|
10534
|
+
return internalError(err, 'ai/models GET');
|
|
10535
|
+
}
|
|
10536
|
+
});
|
|
10537
|
+
router.get('/ai/usage/summary', async (request) => {
|
|
10538
|
+
try {
|
|
10539
|
+
const auth = await requireAuth(request);
|
|
10540
|
+
if (auth.error)
|
|
10541
|
+
return auth.error;
|
|
10542
|
+
const adminErr = requireAdminScope(auth.session);
|
|
10543
|
+
if (adminErr)
|
|
10544
|
+
return adminErr;
|
|
10545
|
+
const { getUsageSummary } = await import('../ai/index.js');
|
|
10546
|
+
const summary = await getUsageSummary(db());
|
|
10547
|
+
return json({ data: summary });
|
|
10548
|
+
}
|
|
10549
|
+
catch (err) {
|
|
10550
|
+
return internalError(err, 'ai/usage/summary GET');
|
|
10551
|
+
}
|
|
10552
|
+
});
|
|
10076
10553
|
router.put('/security', async (request) => {
|
|
10077
10554
|
try {
|
|
10078
10555
|
const auth = await requireAuth(request);
|