plugin-ai-api 1.1.0 → 1.1.2
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/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/97.1bc5103fd9d995a8.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/97.96da323832251796.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +38 -1
- package/dist/locale/vi-VN.json +177 -138
- package/dist/locale/zh-CN.json +177 -138
- package/dist/server/billing.js +17 -9
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-model-prices.js +8 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/auth.js +16 -0
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +34 -5
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +26 -4
- package/dist/server/utils/direct-llm-context.js +10 -9
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +45 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/index.tsx +10 -10
- package/src/client/models/index.ts +12 -12
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +38 -1
- package/src/locale/vi-VN.json +177 -138
- package/src/locale/zh-CN.json +177 -138
- package/src/server/__tests__/billing-quota.test.ts +8 -2
- package/src/server/__tests__/billing.test.ts +13 -0
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +15 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/billing.ts +29 -6
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-model-prices.ts +8 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/auth.ts +21 -1
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +42 -6
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +30 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +23 -12
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +50 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Migration } from '@nocobase/server';
|
|
11
|
+
|
|
12
|
+
const SAMPLE_IDS = ['resp_sample_welcome', 'resp_sample_followup'];
|
|
13
|
+
|
|
14
|
+
export default class SeedSampleResponseRecord extends Migration {
|
|
15
|
+
on = 'afterSync' as const;
|
|
16
|
+
|
|
17
|
+
async up() {
|
|
18
|
+
const collection = this.db.getCollection('aiApiResponseRecords');
|
|
19
|
+
if (!collection || !(await collection.existsInDb())) return;
|
|
20
|
+
|
|
21
|
+
// Never seed demo content in production unless the operator opts in explicitly.
|
|
22
|
+
if (process.env.NODE_ENV === 'production' && process.env.AI_API_SEED_SAMPLE_RESPONSES !== 'true') {
|
|
23
|
+
this.app.logger.info(
|
|
24
|
+
'[ai-api] Skipped sample response records in production (set AI_API_SEED_SAMPLE_RESPONSES=true to enable).',
|
|
25
|
+
);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rootUser = await this.db.getRepository('users').findOne({ filter: { 'roles.name': 'root' } });
|
|
30
|
+
if (!rootUser) {
|
|
31
|
+
this.app.logger.warn('[ai-api] Skipped sample response records because no root user exists.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const repository = this.db.getRepository('aiApiResponseRecords');
|
|
35
|
+
|
|
36
|
+
const now = Math.floor(Date.now() / 1000);
|
|
37
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
38
|
+
const firstOutput = {
|
|
39
|
+
id: SAMPLE_IDS[0],
|
|
40
|
+
object: 'response',
|
|
41
|
+
created_at: now,
|
|
42
|
+
completed_at: now,
|
|
43
|
+
status: 'completed',
|
|
44
|
+
error: null,
|
|
45
|
+
incomplete_details: null,
|
|
46
|
+
instructions: null,
|
|
47
|
+
max_output_tokens: null,
|
|
48
|
+
model: 'sample/gpt-4',
|
|
49
|
+
output: [
|
|
50
|
+
{
|
|
51
|
+
id: 'msg_sample_welcome',
|
|
52
|
+
type: 'message',
|
|
53
|
+
status: 'completed',
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: 'output_text',
|
|
58
|
+
text: 'Hello! Welcome to the NocoBase AI API. How can I help you today?',
|
|
59
|
+
annotations: [],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
output_text: 'Hello! Welcome to the NocoBase AI API. How can I help you today?',
|
|
65
|
+
parallel_tool_calls: true,
|
|
66
|
+
previous_response_id: null,
|
|
67
|
+
reasoning: null,
|
|
68
|
+
service_tier: 'default',
|
|
69
|
+
temperature: null,
|
|
70
|
+
text: null,
|
|
71
|
+
tool_choice: 'auto',
|
|
72
|
+
tools: [],
|
|
73
|
+
top_p: null,
|
|
74
|
+
truncation: 'disabled',
|
|
75
|
+
usage: {
|
|
76
|
+
input_tokens: 15,
|
|
77
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
78
|
+
output_tokens: 20,
|
|
79
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
80
|
+
total_tokens: 35,
|
|
81
|
+
},
|
|
82
|
+
metadata: { sample: 'true', purpose: 'documentation' },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const firstValues = {
|
|
86
|
+
responseId: SAMPLE_IDS[0],
|
|
87
|
+
userId: rootUser.get('id'),
|
|
88
|
+
model: firstOutput.model,
|
|
89
|
+
input: 'Hello! I am testing the Responses API.',
|
|
90
|
+
output: firstOutput,
|
|
91
|
+
previousResponseId: null,
|
|
92
|
+
metadata: firstOutput.metadata,
|
|
93
|
+
expiresAt,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const secondOutput = {
|
|
97
|
+
...firstOutput,
|
|
98
|
+
id: SAMPLE_IDS[1],
|
|
99
|
+
created_at: now + 60,
|
|
100
|
+
completed_at: now + 60,
|
|
101
|
+
output: [
|
|
102
|
+
{
|
|
103
|
+
id: 'msg_sample_followup',
|
|
104
|
+
type: 'message',
|
|
105
|
+
status: 'completed',
|
|
106
|
+
role: 'assistant',
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: 'output_text',
|
|
110
|
+
text: 'I can help with questions, code, data analysis, and more.',
|
|
111
|
+
annotations: [],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
output_text: 'I can help with questions, code, data analysis, and more.',
|
|
117
|
+
previous_response_id: SAMPLE_IDS[0],
|
|
118
|
+
usage: {
|
|
119
|
+
input_tokens: 25,
|
|
120
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
121
|
+
output_tokens: 15,
|
|
122
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
123
|
+
total_tokens: 40,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const secondValues = {
|
|
127
|
+
responseId: SAMPLE_IDS[1],
|
|
128
|
+
userId: rootUser.get('id'),
|
|
129
|
+
model: secondOutput.model,
|
|
130
|
+
input: 'What can you help me with?',
|
|
131
|
+
output: secondOutput,
|
|
132
|
+
previousResponseId: SAMPLE_IDS[0],
|
|
133
|
+
metadata: secondOutput.metadata,
|
|
134
|
+
expiresAt,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Create each missing sample independently inside one transaction, so a rerun after a
|
|
138
|
+
// partial failure still seeds the remaining record without duplicating the existing one.
|
|
139
|
+
const candidates = [firstValues, secondValues];
|
|
140
|
+
await this.db.sequelize.transaction(async (transaction) => {
|
|
141
|
+
for (const values of candidates) {
|
|
142
|
+
const exists = await repository.findOne({ filter: { responseId: values.responseId }, transaction });
|
|
143
|
+
if (!exists) {
|
|
144
|
+
await repository.create({ values, transaction });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
this.app.logger.info('[ai-api] Seeded sample Responses API records for the root user.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async down() {
|
|
152
|
+
const repository = this.db.getRepository('aiApiResponseRecords');
|
|
153
|
+
const records = await repository.find({ filter: { responseId: { $in: SAMPLE_IDS } } });
|
|
154
|
+
for (const record of records) {
|
|
155
|
+
const metadata = record.get('metadata');
|
|
156
|
+
// Only remove records we actually seeded — a user-owned record may reuse a sample id.
|
|
157
|
+
if (metadata && typeof metadata === 'object' && (metadata as Record<string, unknown>).sample === 'true') {
|
|
158
|
+
await repository.destroy({ filterByTk: record.get('id') });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/server/plugin.ts
CHANGED
|
@@ -12,11 +12,12 @@ import type { Transactionable } from '@nocobase/database';
|
|
|
12
12
|
import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
|
|
13
13
|
import aiApiConfigResource from './resource/ai-api-config';
|
|
14
14
|
import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
|
|
15
|
-
import aiApiUsageGroupsResource from './resource/ai-api-usage-groups';
|
|
16
15
|
import { RateLimiter } from './utils/rate-limiter';
|
|
17
16
|
import { invalidateRolePermissionCache } from './middleware/role-permission';
|
|
17
|
+
import { blockResponseRecordResource } from './middleware/response-record-resource';
|
|
18
18
|
import { invalidateGroupAccessCache } from './utils/user-permissions';
|
|
19
|
-
import {
|
|
19
|
+
import { cleanupExpiredResponseRecords } from './utils/response-store';
|
|
20
|
+
import { validateModelPrice, validateModelMetadata, validateQuotaPolicy, validateVirtualModel } from './validation';
|
|
20
21
|
import { AI_API_ACL_SNIPPET } from '../constants';
|
|
21
22
|
import {
|
|
22
23
|
FileProcessorService,
|
|
@@ -52,6 +53,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
52
53
|
fileProcessorService = new FileProcessorService();
|
|
53
54
|
|
|
54
55
|
private gcInterval: NodeJS.Timeout | null = null;
|
|
56
|
+
private responseCleanupInterval: NodeJS.Timeout | null = null;
|
|
55
57
|
|
|
56
58
|
async afterAdd() {}
|
|
57
59
|
|
|
@@ -62,6 +64,9 @@ export class PluginAiApiServer extends Plugin {
|
|
|
62
64
|
this.app.db.on('aiApiModelMetadata.beforeSave', (model) => {
|
|
63
65
|
validateModelMetadata(model);
|
|
64
66
|
});
|
|
67
|
+
this.app.db.on('aiApiVirtualModels.beforeSave', (model) => {
|
|
68
|
+
validateVirtualModel(model);
|
|
69
|
+
});
|
|
65
70
|
this.app.db.on('aiApiUsageGroups.beforeSave', async (model, options) => {
|
|
66
71
|
validateQuotaPolicy(model);
|
|
67
72
|
// The partial unique index on isDefault is not supported on MySQL,
|
|
@@ -82,6 +87,19 @@ export class PluginAiApiServer extends Plugin {
|
|
|
82
87
|
this.app.db.on('aiApiGroupMembers.beforeSave', async (model, options) => {
|
|
83
88
|
const userId = model.get('userId');
|
|
84
89
|
if (!userId) return;
|
|
90
|
+
// The default group is a fallback, not a membership target: an explicit row would pin the
|
|
91
|
+
// user there and make them invisible to any unassigned-users filter. Reject it here so the
|
|
92
|
+
// rule holds whether the row comes from a custom action or a plain aiApiGroupMembers:create.
|
|
93
|
+
const targetGroupId = model.get('groupId');
|
|
94
|
+
if (targetGroupId) {
|
|
95
|
+
const targetGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
96
|
+
filterByTk: targetGroupId,
|
|
97
|
+
transaction: options?.transaction,
|
|
98
|
+
});
|
|
99
|
+
if (targetGroup?.get('isDefault')) {
|
|
100
|
+
throw new Error('Cannot add members to the default group: users without another group use it automatically.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
85
103
|
const existing = await this.db.getRepository('aiApiGroupMembers').findOne({
|
|
86
104
|
filter: { userId },
|
|
87
105
|
transaction: options?.transaction,
|
|
@@ -95,24 +113,16 @@ export class PluginAiApiServer extends Plugin {
|
|
|
95
113
|
if (model.get('isDefault')) {
|
|
96
114
|
throw new Error('The default usage group cannot be deleted.');
|
|
97
115
|
}
|
|
116
|
+
// Drop membership rows so the affected users fall back to the default group
|
|
117
|
+
// implicitly. Rows are destroyed one by one to keep the repository-level
|
|
118
|
+
// destroy events firing for any listener that tracks membership changes.
|
|
98
119
|
const members = await this.db.getRepository('aiApiGroupMembers').find({
|
|
99
120
|
filter: { groupId: model.get('id') },
|
|
100
121
|
transaction: options?.transaction,
|
|
101
122
|
});
|
|
102
|
-
if (members.length === 0) return;
|
|
103
|
-
|
|
104
|
-
const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
105
|
-
filter: { isDefault: true },
|
|
106
|
-
transaction: options?.transaction,
|
|
107
|
-
});
|
|
108
|
-
if (!defaultGroup) {
|
|
109
|
-
throw new Error('Default usage group is missing; cannot reassign members.');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
123
|
for (const member of members) {
|
|
113
|
-
await this.db.getRepository('aiApiGroupMembers').
|
|
124
|
+
await this.db.getRepository('aiApiGroupMembers').destroy({
|
|
114
125
|
filterByTk: member.get('id'),
|
|
115
|
-
values: { groupId: defaultGroup.get('id') },
|
|
116
126
|
transaction: options?.transaction,
|
|
117
127
|
});
|
|
118
128
|
}
|
|
@@ -151,13 +161,20 @@ export class PluginAiApiServer extends Plugin {
|
|
|
151
161
|
// 2. Register admin config resource
|
|
152
162
|
this.app.resourceManager.define(aiApiConfigResource);
|
|
153
163
|
this.app.resourceManager.define(aiApiUsageMonitorResource);
|
|
154
|
-
this.app.resourceManager.define(aiApiUsageGroupsResource);
|
|
155
164
|
|
|
156
|
-
|
|
157
|
-
|
|
165
|
+
// Stored prompts are only exposed through the owner-scoped OpenAI routes. This
|
|
166
|
+
// explicit policy also blocks root, whose generic NocoBase ACL bypass is unconditional.
|
|
167
|
+
this.app.resourceManager.use(blockResponseRecordResource(), {
|
|
168
|
+
tag: 'aiApiResponseRecordsPrivate',
|
|
169
|
+
after: 'auth',
|
|
170
|
+
before: 'acl',
|
|
158
171
|
});
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
|
|
173
|
+
this.app.db.on('aiApiRolePermissions.afterSave', (model, options) => {
|
|
174
|
+
this.invalidateRolePermissionSync(model.get('roleName'), options?.transaction);
|
|
175
|
+
});
|
|
176
|
+
this.app.db.on('aiApiRolePermissions.afterDestroy', (model, options) => {
|
|
177
|
+
this.invalidateRolePermissionSync(model.get('roleName'), options?.transaction);
|
|
161
178
|
});
|
|
162
179
|
|
|
163
180
|
this.app.db.on('aiApiUsageGroups.afterSave', (model, options) => {
|
|
@@ -175,6 +192,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
175
192
|
'aiApiRolePermissions:*',
|
|
176
193
|
'aiApiModelPrices:*',
|
|
177
194
|
'aiApiModelMetadata:*',
|
|
195
|
+
'aiApiVirtualModels:*',
|
|
178
196
|
'aiApiUsageGroups:*',
|
|
179
197
|
'aiApiGroupMembers:*',
|
|
180
198
|
'aiApiGroupQuotaBuckets:list',
|
|
@@ -189,6 +207,24 @@ export class PluginAiApiServer extends Plugin {
|
|
|
189
207
|
// .unref() prevents this timer from keeping the process alive on shutdown.
|
|
190
208
|
this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
|
|
191
209
|
this.gcInterval.unref();
|
|
210
|
+
|
|
211
|
+
// Expired stored responses are removed daily; run once at startup as well.
|
|
212
|
+
this.cleanupResponseRecords();
|
|
213
|
+
this.responseCleanupInterval = setInterval(() => this.cleanupResponseRecords(), 24 * 60 * 60 * 1000);
|
|
214
|
+
this.responseCleanupInterval.unref();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Drop a role's cached permission record on every node.
|
|
219
|
+
*
|
|
220
|
+
* Mirrors invalidateGroupAccess: the local call is not redundant because
|
|
221
|
+
* syncMessageManager hardcodes skipSelf, so the publishing node never receives
|
|
222
|
+
* its own message. Passing the transaction defers the broadcast until the
|
|
223
|
+
* write commits, so other nodes cannot re-read the old row and re-cache it.
|
|
224
|
+
*/
|
|
225
|
+
private invalidateRolePermissionSync(roleName: unknown, transaction?: Transactionable['transaction']) {
|
|
226
|
+
invalidateRolePermissionCache(roleName as string | undefined);
|
|
227
|
+
this.sendSyncMessage({ type: 'invalidateRolePermission', roleName }, { transaction });
|
|
192
228
|
}
|
|
193
229
|
|
|
194
230
|
/**
|
|
@@ -206,9 +242,11 @@ export class PluginAiApiServer extends Plugin {
|
|
|
206
242
|
/**
|
|
207
243
|
* Received only on the *other* nodes (skipSelf), so this must not re-broadcast.
|
|
208
244
|
*/
|
|
209
|
-
async handleSyncMessage(message: { type?: string; groupId?: unknown }) {
|
|
245
|
+
async handleSyncMessage(message: { type?: string; groupId?: unknown; roleName?: unknown }) {
|
|
210
246
|
if (message?.type === 'invalidateGroupAccess') {
|
|
211
247
|
invalidateGroupAccessCache(message.groupId as string | number | bigint);
|
|
248
|
+
} else if (message?.type === 'invalidateRolePermission') {
|
|
249
|
+
invalidateRolePermissionCache(message.roleName as string | undefined);
|
|
212
250
|
}
|
|
213
251
|
}
|
|
214
252
|
|
|
@@ -227,6 +265,19 @@ export class PluginAiApiServer extends Plugin {
|
|
|
227
265
|
});
|
|
228
266
|
}
|
|
229
267
|
|
|
268
|
+
// Root and admin get explicit AI API permission rows on fresh installs. There is no
|
|
269
|
+
// built-in role bypass anymore, so without these rows even root/admin would be denied
|
|
270
|
+
// until an admin grants access in Settings → Users & Permissions.
|
|
271
|
+
for (const roleName of ['root', 'admin']) {
|
|
272
|
+
const perm = await this.db.getRepository('aiApiRolePermissions').findOne({
|
|
273
|
+
filter: { roleName },
|
|
274
|
+
});
|
|
275
|
+
if (!perm) {
|
|
276
|
+
await this.db.getRepository('aiApiRolePermissions').create({
|
|
277
|
+
values: { roleName, enabled: true, allowAllEmployees: true, allowedEmployees: [] },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
230
281
|
// Create default usage group on first install
|
|
231
282
|
const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
232
283
|
filter: { isDefault: true },
|
|
@@ -256,6 +307,15 @@ export class PluginAiApiServer extends Plugin {
|
|
|
256
307
|
}
|
|
257
308
|
}
|
|
258
309
|
|
|
310
|
+
private async cleanupResponseRecords(): Promise<void> {
|
|
311
|
+
try {
|
|
312
|
+
const deleted = await cleanupExpiredResponseRecords({ db: this.db });
|
|
313
|
+
if (deleted > 0) this.app.logger.info(`[ai-api] Cleaned up ${deleted} expired response records`);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
this.app.logger.warn('[ai-api] Failed to clean up expired response records:', error);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
259
319
|
async afterEnable() {}
|
|
260
320
|
|
|
261
321
|
async afterDisable() {}
|
|
@@ -266,6 +326,10 @@ export class PluginAiApiServer extends Plugin {
|
|
|
266
326
|
clearInterval(this.gcInterval);
|
|
267
327
|
this.gcInterval = null;
|
|
268
328
|
}
|
|
329
|
+
if (this.responseCleanupInterval) {
|
|
330
|
+
clearInterval(this.responseCleanupInterval);
|
|
331
|
+
this.responseCleanupInterval = null;
|
|
332
|
+
}
|
|
269
333
|
this.rateLimiter.clear();
|
|
270
334
|
}
|
|
271
335
|
}
|
|
@@ -59,7 +59,8 @@ const aiApiConfigResource: ResourceOptions = {
|
|
|
59
59
|
},
|
|
60
60
|
|
|
61
61
|
async save(ctx, next) {
|
|
62
|
-
const values
|
|
62
|
+
const values: Record<string, unknown> =
|
|
63
|
+
ctx.action.params.values || (ctx.request.body as Record<string, unknown>) || {};
|
|
63
64
|
const repo = ctx.db.getRepository('aiApiConfig');
|
|
64
65
|
let config = await repo.findOne();
|
|
65
66
|
|
|
@@ -104,6 +104,9 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// ─── Resolve model ─────────────────────────────────────────────────────────
|
|
107
|
+
// Agent mode runs the AI Employee pipeline, so virtual aliases ("auto") are intentionally
|
|
108
|
+
// not resolved here: capability-bucket routing is a direct-LLM concept. The model field
|
|
109
|
+
// selects the concrete model the employee uses.
|
|
107
110
|
const resolved = await resolveModelString(ctx, body.model);
|
|
108
111
|
if (!resolved) {
|
|
109
112
|
ctx.status = 404;
|
|
@@ -75,7 +75,7 @@ export async function authenticateBearer(ctx: Context): Promise<boolean> {
|
|
|
75
75
|
return false;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
let decoded: { userId?: string | number; roleName?: string; temp?: boolean };
|
|
78
|
+
let decoded: { userId?: string | number; roleName?: string; temp?: boolean; jti?: string };
|
|
79
79
|
try {
|
|
80
80
|
decoded = await jwt.decode(token);
|
|
81
81
|
} catch (e) {
|
|
@@ -90,6 +90,26 @@ export async function authenticateBearer(ctx: Context): Promise<boolean> {
|
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Enforce the token blacklist here as well. Revoked API keys are blocked by
|
|
94
|
+
// the core NocoBase auth path (Auth.checkToken), but this API-key fallback
|
|
95
|
+
// decodes the JWT directly and would otherwise keep accepting them.
|
|
96
|
+
if (jwt.blacklist) {
|
|
97
|
+
let blocked = false;
|
|
98
|
+
try {
|
|
99
|
+
blocked = await jwt.blacklist.has(decoded.jti ?? token);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
ctx.log.error('AI API token blacklist check failed:', err);
|
|
102
|
+
ctx.status = 401;
|
|
103
|
+
ctx.body = toOpenAIError(401, 'Unable to verify API key status', 'server_error');
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (blocked) {
|
|
107
|
+
ctx.status = 401;
|
|
108
|
+
ctx.body = toOpenAIError(401, 'API key has been revoked', 'invalid_request_error', 'invalid_api_key');
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
93
113
|
// This fallback is for API keys only. A normal login/OIDC token should have
|
|
94
114
|
// been resolved by NocoBase auth middleware and must not be treated as the
|
|
95
115
|
// member role merely because it has no roleName claim.
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
OpenAIToolCallChunk,
|
|
21
21
|
} from '../utils/openai-format';
|
|
22
22
|
import { resolveModelString } from '../utils/resolve-service';
|
|
23
|
+
import { resolveVirtualModel, respondVirtualModelUnavailable } from '../utils/virtual-models';
|
|
23
24
|
import {
|
|
24
25
|
createRequestAbortController,
|
|
25
26
|
isClientDisconnected,
|
|
@@ -96,8 +97,17 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
96
97
|
|
|
97
98
|
const stream = isStreamingRequested(body.stream);
|
|
98
99
|
|
|
99
|
-
// ─── Resolve model string against DB ───
|
|
100
|
-
const
|
|
100
|
+
// ─── Resolve model string against DB (virtual alias first, then concrete) ───
|
|
101
|
+
const virtual = await resolveVirtualModel(ctx, body.model, body, 'chat');
|
|
102
|
+
if (virtual?.status === 'unavailable') {
|
|
103
|
+
respondVirtualModelUnavailable(ctx, virtual);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (virtual?.status === 'resolved') {
|
|
107
|
+
ctx.state.aiApiVirtualModel = virtual.virtualModel;
|
|
108
|
+
ctx.state.aiApiRoutingReason = virtual.reason;
|
|
109
|
+
}
|
|
110
|
+
const resolved = virtual?.resolved ?? (await resolveModelString(ctx, body.model));
|
|
101
111
|
if (!resolved) {
|
|
102
112
|
ctx.status = 404;
|
|
103
113
|
ctx.body = toOpenAIError(
|
|
@@ -236,7 +246,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
236
246
|
chatModel,
|
|
237
247
|
langchainMessages,
|
|
238
248
|
completionId,
|
|
239
|
-
|
|
249
|
+
`${service.name}/${modelId}`,
|
|
240
250
|
providerRequestParameters,
|
|
241
251
|
);
|
|
242
252
|
} else {
|
|
@@ -246,7 +256,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
246
256
|
chatModel,
|
|
247
257
|
langchainMessages,
|
|
248
258
|
completionId,
|
|
249
|
-
|
|
259
|
+
`${service.name}/${modelId}`,
|
|
250
260
|
providerRequestParameters,
|
|
251
261
|
);
|
|
252
262
|
}
|
|
@@ -722,7 +732,7 @@ function describeImageUrlProblem(imageUrl: unknown): string | undefined {
|
|
|
722
732
|
* A LangChain message content value: plain text, or an array of content blocks
|
|
723
733
|
* (`{type:'text'}`, `{type:'image_url'}`, ...) for multimodal requests.
|
|
724
734
|
*/
|
|
725
|
-
type MessageContent = string | Record<string, unknown>[];
|
|
735
|
+
export type MessageContent = string | Record<string, unknown>[];
|
|
726
736
|
|
|
727
737
|
/**
|
|
728
738
|
* Normalize an OpenAI `message.content` into something LangChain accepts.
|
|
@@ -763,7 +773,7 @@ export function normalizeMessageContent(content: unknown): MessageContent {
|
|
|
763
773
|
* block (e.g. a `file_url` becomes a `file` block, which may then be converted
|
|
764
774
|
* to images by the PDF processor).
|
|
765
775
|
*/
|
|
766
|
-
async function processMessageContentFileBlocks(
|
|
776
|
+
export async function processMessageContentFileBlocks(
|
|
767
777
|
content: unknown,
|
|
768
778
|
ctx: Context,
|
|
769
779
|
plugin: PluginAiApiServer,
|
|
@@ -807,7 +817,21 @@ async function processFileBlockChain(
|
|
|
807
817
|
return next;
|
|
808
818
|
}
|
|
809
819
|
|
|
810
|
-
const GATEWAY_MANAGED_PARAMETERS = new Set([
|
|
820
|
+
const GATEWAY_MANAGED_PARAMETERS = new Set([
|
|
821
|
+
'model',
|
|
822
|
+
'messages',
|
|
823
|
+
'prompt',
|
|
824
|
+
'tools',
|
|
825
|
+
'tool_choice',
|
|
826
|
+
'stream',
|
|
827
|
+
'n',
|
|
828
|
+
// Responses API specific fields that should not be passed to providers
|
|
829
|
+
'input',
|
|
830
|
+
'previous_response_id',
|
|
831
|
+
'store',
|
|
832
|
+
'truncation',
|
|
833
|
+
'metadata',
|
|
834
|
+
]);
|
|
811
835
|
|
|
812
836
|
export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
|
|
813
837
|
return Object.fromEntries(
|
|
@@ -836,7 +860,7 @@ export function applyProviderRequestParameters(chatModel: unknown, parameters: R
|
|
|
836
860
|
model.modelKwargs = { ...modelKwargs, ...parameters };
|
|
837
861
|
}
|
|
838
862
|
|
|
839
|
-
function bindRequestTools(
|
|
863
|
+
export function bindRequestTools(
|
|
840
864
|
chatModel: any,
|
|
841
865
|
tools: unknown,
|
|
842
866
|
toolChoice: unknown,
|
|
@@ -865,7 +889,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
865
889
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
866
890
|
}
|
|
867
891
|
|
|
868
|
-
function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
|
|
892
|
+
export function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
|
|
869
893
|
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
870
894
|
return value.map((call: any) => ({
|
|
871
895
|
id: String(call.id || ''),
|
|
@@ -877,7 +901,7 @@ function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
|
|
|
877
901
|
}));
|
|
878
902
|
}
|
|
879
903
|
|
|
880
|
-
function normalizeToolCallChunks(value: unknown): OpenAIToolCallChunk[] {
|
|
904
|
+
export function normalizeToolCallChunks(value: unknown): OpenAIToolCallChunk[] {
|
|
881
905
|
if (!Array.isArray(value)) return [];
|
|
882
906
|
return value.map((call: any, fallbackIndex) => ({
|
|
883
907
|
index: typeof call.index === 'number' ? call.index : fallbackIndex,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
toOpenAIUsageChunk,
|
|
17
17
|
} from '../utils/openai-format';
|
|
18
18
|
import { resolveModelString } from '../utils/resolve-service';
|
|
19
|
+
import { resolveVirtualModel, respondVirtualModelUnavailable } from '../utils/virtual-models';
|
|
19
20
|
import { enforceModelAccess } from '../utils/user-permissions';
|
|
20
21
|
import {
|
|
21
22
|
createRequestAbortController,
|
|
@@ -70,7 +71,16 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
70
71
|
const stream = isStreamingRequested(body.stream);
|
|
71
72
|
|
|
72
73
|
// ─── Resolve model string against DB ───
|
|
73
|
-
const
|
|
74
|
+
const virtual = await resolveVirtualModel(ctx, body.model, body, 'chat');
|
|
75
|
+
if (virtual?.status === 'unavailable') {
|
|
76
|
+
respondVirtualModelUnavailable(ctx, virtual);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (virtual?.status === 'resolved') {
|
|
80
|
+
ctx.state.aiApiVirtualModel = virtual.virtualModel;
|
|
81
|
+
ctx.state.aiApiRoutingReason = virtual.reason;
|
|
82
|
+
}
|
|
83
|
+
const resolved = virtual?.resolved ?? (await resolveModelString(ctx, body.model));
|
|
74
84
|
if (!resolved) {
|
|
75
85
|
ctx.status = 404;
|
|
76
86
|
ctx.body = toOpenAIError(
|
|
@@ -124,7 +134,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
124
134
|
|
|
125
135
|
if (body.temperature !== undefined) modelOptions.temperature = body.temperature;
|
|
126
136
|
if (body.top_p !== undefined) modelOptions.topP = body.top_p;
|
|
127
|
-
if (body.
|
|
137
|
+
if (body.max_completion_tokens !== undefined) modelOptions.maxTokens = body.max_completion_tokens;
|
|
138
|
+
else if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
|
|
128
139
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
129
140
|
|
|
130
141
|
// ─── Convert prompt to message tuple ───
|
|
@@ -142,6 +153,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
142
153
|
serviceName: service.name,
|
|
143
154
|
modelId,
|
|
144
155
|
messages,
|
|
156
|
+
maxCompletionTokens: body.max_completion_tokens,
|
|
145
157
|
maxTokens: body.max_tokens,
|
|
146
158
|
});
|
|
147
159
|
await prepareLlmBilling(ctx, resolved);
|
|
@@ -169,7 +181,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
169
181
|
chatModel,
|
|
170
182
|
langchainMessages,
|
|
171
183
|
completionId,
|
|
172
|
-
|
|
184
|
+
`${service.name}/${modelId}`,
|
|
173
185
|
body.stream_options,
|
|
174
186
|
providerRequestParameters,
|
|
175
187
|
);
|
|
@@ -179,7 +191,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
179
191
|
chatModel,
|
|
180
192
|
langchainMessages,
|
|
181
193
|
completionId,
|
|
182
|
-
|
|
194
|
+
`${service.name}/${modelId}`,
|
|
183
195
|
providerRequestParameters,
|
|
184
196
|
);
|
|
185
197
|
}
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
|
|
12
12
|
import { resolveModelString } from '../utils/resolve-service';
|
|
13
|
+
import { resolveVirtualModel, respondVirtualModelUnavailable } from '../utils/virtual-models';
|
|
13
14
|
import { enforceModelAccess } from '../utils/user-permissions';
|
|
14
15
|
import { getAiApiConfig } from '../utils/request-cache';
|
|
15
|
-
import {
|
|
16
|
+
import { setAiApiUsageResult } from '../usage';
|
|
16
17
|
import type PluginAiApiServer from '../plugin';
|
|
18
|
+
import { markLlmProviderAttempted, prepareLlmBilling, AiApiQuotaError } from '../billing';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* POST /api/ai-llm/v1/embeddings
|
|
@@ -89,7 +91,16 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// ─── Resolve model ────────────────────────────────────────────────────────
|
|
92
|
-
const
|
|
94
|
+
const virtual = await resolveVirtualModel(ctx, body.model, body, 'embedding');
|
|
95
|
+
if (virtual?.status === 'unavailable') {
|
|
96
|
+
respondVirtualModelUnavailable(ctx, virtual);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (virtual?.status === 'resolved') {
|
|
100
|
+
ctx.state.aiApiVirtualModel = virtual.virtualModel;
|
|
101
|
+
ctx.state.aiApiRoutingReason = virtual.reason;
|
|
102
|
+
}
|
|
103
|
+
const resolved = virtual?.resolved ?? (await resolveModelString(ctx, body.model));
|
|
93
104
|
if (!resolved) {
|
|
94
105
|
ctx.status = 404;
|
|
95
106
|
ctx.body = toOpenAIError(
|
|
@@ -156,7 +167,12 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
156
167
|
return;
|
|
157
168
|
}
|
|
158
169
|
|
|
170
|
+
// ─── Prepare billing/quota after access checks ────────────────────────────
|
|
171
|
+
// Running this after service.enabled and enforceModelAccess avoids a needless
|
|
172
|
+
// reserve/release round-trip on every rejected request.
|
|
173
|
+
|
|
159
174
|
try {
|
|
175
|
+
await prepareLlmBilling(ctx, resolved);
|
|
160
176
|
// ─── Instantiate and call the embedding provider ──────────────────────
|
|
161
177
|
const EmbeddingClass = providerMeta.embedding;
|
|
162
178
|
const embeddingProvider = new EmbeddingClass({
|
|
@@ -168,13 +184,22 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
168
184
|
// createEmbedding() returns a LangChain EmbeddingsInterface.
|
|
169
185
|
// embedDocuments() accepts string[] and returns number[][] (one vector per input).
|
|
170
186
|
const embeddingModel = embeddingProvider.createEmbedding();
|
|
187
|
+
markLlmProviderAttempted(ctx);
|
|
171
188
|
const vectors: number[][] = await embeddingModel.embedDocuments(inputs);
|
|
172
189
|
|
|
190
|
+
// Estimate input tokens: average 4 chars per token (conservative).
|
|
191
|
+
const estimatedInputTokens = Math.ceil(inputs.reduce((sum, s) => sum + s.length, 0) / 4);
|
|
192
|
+
const usage = {
|
|
193
|
+
prompt_tokens: estimatedInputTokens,
|
|
194
|
+
completion_tokens: 0,
|
|
195
|
+
total_tokens: estimatedInputTokens,
|
|
196
|
+
};
|
|
197
|
+
setAiApiUsageResult(ctx, usage);
|
|
198
|
+
|
|
173
199
|
ctx.status = 200;
|
|
174
200
|
ctx.set('Content-Type', 'application/json');
|
|
175
|
-
setAiApiUsageUnavailable(ctx);
|
|
176
201
|
ctx.body = toOpenAIEmbeddingsResponse({
|
|
177
|
-
model:
|
|
202
|
+
model: `${service.name}/${modelId}`,
|
|
178
203
|
embeddings: vectors,
|
|
179
204
|
// LangChain's EmbeddingsInterface does not expose token counts.
|
|
180
205
|
promptTokens: null,
|
|
@@ -182,8 +207,19 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
182
207
|
} catch (err) {
|
|
183
208
|
ctx.log.error('AI API embeddings error:', err);
|
|
184
209
|
if (!ctx.res.headersSent) {
|
|
185
|
-
|
|
186
|
-
ctx.
|
|
210
|
+
const isQuotaError = err instanceof AiApiQuotaError;
|
|
211
|
+
ctx.status = isQuotaError ? 429 : 500;
|
|
212
|
+
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
213
|
+
ctx.body = toOpenAIError(
|
|
214
|
+
ctx.status,
|
|
215
|
+
getErrorMessage(err, 'Failed to generate embeddings'),
|
|
216
|
+
isQuotaError ? 'quota_error' : 'server_error',
|
|
217
|
+
isQuotaError ? err.code : undefined,
|
|
218
|
+
);
|
|
187
219
|
}
|
|
188
220
|
}
|
|
189
221
|
}
|
|
222
|
+
|
|
223
|
+
function getErrorMessage(error: unknown, fallback: string) {
|
|
224
|
+
return error instanceof Error && error.message ? error.message : fallback;
|
|
225
|
+
}
|