plugin-ai-api 1.1.1 → 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/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/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +175 -139
- package/dist/locale/vi-VN.json +40 -2
- package/dist/locale/zh-CN.json +40 -2
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-response-records.js +101 -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/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/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +25 -6
- 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 +25 -4
- package/dist/server/utils/direct-llm-context.js +1 -1
- 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 +44 -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/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/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/locale/en-US.json +175 -139
- package/src/locale/vi-VN.json +40 -2
- package/src/locale/zh-CN.json +40 -2
- 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 +14 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-response-records.ts +77 -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/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/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +32 -10
- 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 +29 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +2 -1
- 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 +49 -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-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.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
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;
|
|
@@ -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,11 +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
16
|
import { setAiApiUsageResult } from '../usage';
|
|
16
17
|
import type PluginAiApiServer from '../plugin';
|
|
17
|
-
import { markLlmProviderAttempted, prepareLlmBilling,
|
|
18
|
+
import { markLlmProviderAttempted, prepareLlmBilling, AiApiQuotaError } from '../billing';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* POST /api/ai-llm/v1/embeddings
|
|
@@ -90,7 +91,16 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
// ─── Resolve model ────────────────────────────────────────────────────────
|
|
93
|
-
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));
|
|
94
104
|
if (!resolved) {
|
|
95
105
|
ctx.status = 404;
|
|
96
106
|
ctx.body = toOpenAIError(
|
|
@@ -104,9 +114,6 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
104
114
|
|
|
105
115
|
const { service, modelId } = resolved;
|
|
106
116
|
|
|
107
|
-
// ─── Prepare billing/quota ────────────────────────────────────────────────
|
|
108
|
-
await prepareLlmBilling(ctx, resolved);
|
|
109
|
-
|
|
110
117
|
if (service.enabled === false) {
|
|
111
118
|
ctx.status = 404;
|
|
112
119
|
ctx.body = toOpenAIError(
|
|
@@ -160,7 +167,12 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
160
167
|
return;
|
|
161
168
|
}
|
|
162
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
|
+
|
|
163
174
|
try {
|
|
175
|
+
await prepareLlmBilling(ctx, resolved);
|
|
164
176
|
// ─── Instantiate and call the embedding provider ──────────────────────
|
|
165
177
|
const EmbeddingClass = providerMeta.embedding;
|
|
166
178
|
const embeddingProvider = new EmbeddingClass({
|
|
@@ -183,12 +195,11 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
183
195
|
total_tokens: estimatedInputTokens,
|
|
184
196
|
};
|
|
185
197
|
setAiApiUsageResult(ctx, usage);
|
|
186
|
-
await finalizeLlmBilling(ctx, usage, true);
|
|
187
198
|
|
|
188
199
|
ctx.status = 200;
|
|
189
200
|
ctx.set('Content-Type', 'application/json');
|
|
190
201
|
ctx.body = toOpenAIEmbeddingsResponse({
|
|
191
|
-
model:
|
|
202
|
+
model: `${service.name}/${modelId}`,
|
|
192
203
|
embeddings: vectors,
|
|
193
204
|
// LangChain's EmbeddingsInterface does not expose token counts.
|
|
194
205
|
promptTokens: null,
|
|
@@ -196,8 +207,19 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
196
207
|
} catch (err) {
|
|
197
208
|
ctx.log.error('AI API embeddings error:', err);
|
|
198
209
|
if (!ctx.res.headersSent) {
|
|
199
|
-
|
|
200
|
-
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
|
+
);
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
|
-
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getErrorMessage(error: unknown, fallback: string) {
|
|
224
|
+
return error instanceof Error && error.message ? error.message : fallback;
|
|
225
|
+
}
|
|
@@ -11,6 +11,7 @@ import { Context } from '@nocobase/actions';
|
|
|
11
11
|
import { toOpenAIError } from '../utils/openai-format';
|
|
12
12
|
import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
|
|
13
13
|
import { getAiApiConfig } from '../utils/request-cache';
|
|
14
|
+
import { listAccessibleVirtualModels, type VirtualModel } from '../utils/virtual-models';
|
|
14
15
|
import type PluginAiApiServer from '../plugin';
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -75,6 +76,17 @@ export async function handleListModels(ctx: Context, plugin: PluginAiApiServer)
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Expose enabled virtual aliases (e.g. "auto") so clients can discover and use them.
|
|
80
|
+
// They carry virtual:true and a description, but no capability overrides of their own.
|
|
81
|
+
try {
|
|
82
|
+
const virtualModels = await listAccessibleVirtualModels(ctx, scope, config?.enabledLlmServices);
|
|
83
|
+
for (const virtualModel of virtualModels) {
|
|
84
|
+
models.push(buildVirtualModelObject(virtualModel, now));
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Virtual models table may not exist yet during a rolling upgrade — skip silently.
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
ctx.status = 200;
|
|
79
91
|
ctx.body = {
|
|
80
92
|
object: 'list',
|
|
@@ -137,6 +149,16 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
137
149
|
if (found) break;
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
if (!found) {
|
|
153
|
+
try {
|
|
154
|
+
const virtualModels = await listAccessibleVirtualModels(ctx, scope, config?.enabledLlmServices);
|
|
155
|
+
const virtualModel = virtualModels.find((candidate) => candidate.name === modelId);
|
|
156
|
+
if (virtualModel) found = buildVirtualModelObject(virtualModel, now);
|
|
157
|
+
} catch {
|
|
158
|
+
// Virtual models table may not exist yet during a rolling upgrade.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
140
162
|
if (!found) {
|
|
141
163
|
ctx.status = 404;
|
|
142
164
|
ctx.body = toOpenAIError(404, `Model '${modelId}' not found`, 'invalid_request_error', 'model_not_found');
|
|
@@ -246,6 +268,18 @@ export function buildModelObject(
|
|
|
246
268
|
return model;
|
|
247
269
|
}
|
|
248
270
|
|
|
271
|
+
export function buildVirtualModelObject(virtualModel: VirtualModel, created: number): Record<string, unknown> {
|
|
272
|
+
return {
|
|
273
|
+
id: virtualModel.name,
|
|
274
|
+
object: 'model',
|
|
275
|
+
created,
|
|
276
|
+
owned_by: 'ai-api-gateway',
|
|
277
|
+
virtual: true,
|
|
278
|
+
mode: virtualModel.mode,
|
|
279
|
+
description: `Virtual ${virtualModel.mode} alias routed to a concrete model based on the request shape.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
249
283
|
function toPositiveInt(value: unknown): number | null {
|
|
250
284
|
const n = Number(value);
|
|
251
285
|
return Number.isSafeInteger(n) && n > 0 ? n : null;
|