plugin-ai-api 1.0.25 → 1.1.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/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/locale/en-US.json +26 -8
- package/dist/locale/vi-VN.json +26 -8
- package/dist/locale/zh-CN.json +26 -8
- package/dist/server/billing.js +25 -32
- package/dist/server/collections/ai-api-config.js +1 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +90 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +0 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +32 -32
- package/dist/server/routes/completions.js +16 -19
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +186 -22
- package/dist/server/usage.js +5 -1
- package/dist/server/utils/direct-llm-context.js +13 -11
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +6 -7
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +26 -8
- package/src/locale/vi-VN.json +26 -8
- package/src/locale/zh-CN.json +26 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +122 -4
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +262 -2
- package/src/server/__tests__/usage.test.ts +38 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +30 -38
- package/src/server/collections/ai-api-config.ts +1 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/middleware/rate-limit.ts +10 -12
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +101 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +0 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +39 -36
- package/src/server/routes/completions.ts +18 -21
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/file-processor.ts +214 -24
- package/src/server/usage.ts +5 -1
- package/src/server/utils/direct-llm-context.ts +20 -11
- package/src/server/utils/rate-limiter.ts +1 -1
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +2 -1
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +7 -8
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- package/src/server/resource/ai-api-user-permissions.ts +0 -76
|
@@ -0,0 +1,171 @@
|
|
|
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 { Context } from '@nocobase/actions';
|
|
11
|
+
|
|
12
|
+
const USER_SUMMARY_FIELDS = ['id', 'username', 'nickname', 'email'];
|
|
13
|
+
|
|
14
|
+
async function getByUser(ctx: Context, next: () => Promise<void>) {
|
|
15
|
+
const { userId } = ctx.action.params;
|
|
16
|
+
if (!userId) {
|
|
17
|
+
ctx.throw(400, 'userId is required');
|
|
18
|
+
}
|
|
19
|
+
const member = await ctx.db.getRepository('aiApiGroupMembers').findOne({
|
|
20
|
+
filter: { userId },
|
|
21
|
+
appends: ['group'],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (member && member.get('group')) {
|
|
25
|
+
ctx.body = member.get('group');
|
|
26
|
+
return next();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultGroup = await ctx.db.getRepository('aiApiUsageGroups').findOne({
|
|
30
|
+
filter: { isDefault: true },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!defaultGroup) {
|
|
34
|
+
ctx.throw(404, 'Default group not found');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ctx.body = defaultGroup;
|
|
38
|
+
return next();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listUnassignedUsers(ctx: Context, next: () => Promise<void>) {
|
|
42
|
+
const { page = 1, pageSize = 20, keyword } = ctx.action.params;
|
|
43
|
+
|
|
44
|
+
const memberRows = (await ctx.db.getRepository('aiApiGroupMembers').find({
|
|
45
|
+
fields: ['userId'],
|
|
46
|
+
pageSize: 10000,
|
|
47
|
+
})) as unknown[];
|
|
48
|
+
const assignedUserIds = memberRows.map((row) => {
|
|
49
|
+
const value = (row as Record<string, unknown>).get?.('userId') ?? (row as Record<string, unknown>).userId;
|
|
50
|
+
return String(value);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const where: Record<string, unknown> = {};
|
|
54
|
+
if (keyword) {
|
|
55
|
+
where.$or = [
|
|
56
|
+
{ username: { $includes: keyword } },
|
|
57
|
+
{ email: { $includes: keyword } },
|
|
58
|
+
{ nickname: { $includes: keyword } },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
if (assignedUserIds.length > 0) {
|
|
62
|
+
where.id = { $notIn: assignedUserIds };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const repository = ctx.db.getRepository('users');
|
|
66
|
+
const [rows, count] = await repository.findAndCount({
|
|
67
|
+
filter: where,
|
|
68
|
+
fields: USER_SUMMARY_FIELDS,
|
|
69
|
+
page: Number(page),
|
|
70
|
+
pageSize: Number(pageSize),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ctx.body = { rows, count };
|
|
74
|
+
return next();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function searchUsers(ctx: Context, next: () => Promise<void>) {
|
|
78
|
+
const { keyword, pageSize = 20 } = ctx.action.params;
|
|
79
|
+
|
|
80
|
+
const where: Record<string, unknown> = {};
|
|
81
|
+
if (keyword) {
|
|
82
|
+
where.$or = [
|
|
83
|
+
{ username: { $includes: keyword } },
|
|
84
|
+
{ email: { $includes: keyword } },
|
|
85
|
+
{ nickname: { $includes: keyword } },
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [rows, count] = await ctx.db.getRepository('users').findAndCount({
|
|
90
|
+
filter: where,
|
|
91
|
+
fields: USER_SUMMARY_FIELDS,
|
|
92
|
+
page: 1,
|
|
93
|
+
pageSize: Number(pageSize),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ctx.body = { rows, count };
|
|
97
|
+
return next();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function addMember(ctx: Context, next: () => Promise<void>) {
|
|
101
|
+
const { groupId, userId } = ctx.action.params.values || ctx.action.params || {};
|
|
102
|
+
if (!groupId || !userId) {
|
|
103
|
+
ctx.throw(400, 'groupId and userId are required');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const group = await ctx.db.getRepository('aiApiUsageGroups').findOne({
|
|
107
|
+
filterByTk: groupId,
|
|
108
|
+
});
|
|
109
|
+
if (!group) {
|
|
110
|
+
ctx.throw(404, 'Usage group not found');
|
|
111
|
+
}
|
|
112
|
+
// The default group is a fallback, not a membership target: an explicit row would
|
|
113
|
+
// pin the user there and hide them from listUnassignedUsers forever.
|
|
114
|
+
if (group.get('isDefault')) {
|
|
115
|
+
ctx.throw(400, 'Cannot add members to the default group: users without another group use it automatically');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const existing = await ctx.db.getRepository('aiApiGroupMembers').findOne({
|
|
119
|
+
filter: { userId },
|
|
120
|
+
});
|
|
121
|
+
if (existing) {
|
|
122
|
+
ctx.throw(409, 'User already belongs to a group');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const member = await ctx.db.getRepository('aiApiGroupMembers').create({
|
|
126
|
+
values: { groupId, userId },
|
|
127
|
+
});
|
|
128
|
+
ctx.body = member;
|
|
129
|
+
return next();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function removeMember(ctx: Context, next: () => Promise<void>) {
|
|
133
|
+
const { groupId, userId } = ctx.action.params.values || ctx.action.params || {};
|
|
134
|
+
if (!groupId || !userId) {
|
|
135
|
+
ctx.throw(400, 'groupId and userId are required');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const member = await ctx.db.getRepository('aiApiGroupMembers').findOne({
|
|
139
|
+
filter: { groupId, userId },
|
|
140
|
+
});
|
|
141
|
+
if (!member) {
|
|
142
|
+
ctx.throw(404, 'Member not found');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await ctx.db.getRepository('aiApiGroupMembers').destroy({
|
|
146
|
+
filterByTk: member.get('id'),
|
|
147
|
+
});
|
|
148
|
+
ctx.body = member;
|
|
149
|
+
return next();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default {
|
|
153
|
+
name: 'aiApiUsageGroups',
|
|
154
|
+
actions: {
|
|
155
|
+
getByUser: {
|
|
156
|
+
handler: getByUser,
|
|
157
|
+
},
|
|
158
|
+
listUnassignedUsers: {
|
|
159
|
+
handler: listUnassignedUsers,
|
|
160
|
+
},
|
|
161
|
+
searchUsers: {
|
|
162
|
+
handler: searchUsers,
|
|
163
|
+
},
|
|
164
|
+
addMember: {
|
|
165
|
+
handler: addMember,
|
|
166
|
+
},
|
|
167
|
+
removeMember: {
|
|
168
|
+
handler: removeMember,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { resolveModelString } from '../utils/resolve-service';
|
|
20
20
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
21
21
|
import { enforceModelAccess } from '../utils/user-permissions';
|
|
22
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
22
23
|
import { isStreamingRequested } from '../utils/streaming';
|
|
23
24
|
import {
|
|
24
25
|
AgentRuntimeContext,
|
|
@@ -89,7 +90,7 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
// ─── Load config and validate AI Employee ─────────────────────────────────
|
|
92
|
-
const config = await ctx
|
|
93
|
+
const config = await getAiApiConfig(ctx);
|
|
93
94
|
const defaultAiEmployee = config ? config.get('defaultAiEmployee') || config.defaultAiEmployee : null;
|
|
94
95
|
if (!defaultAiEmployee) {
|
|
95
96
|
ctx.status = 400;
|
|
@@ -26,8 +26,8 @@ import {
|
|
|
26
26
|
isStreamingRequested,
|
|
27
27
|
writeResponse,
|
|
28
28
|
} from '../utils/streaming';
|
|
29
|
-
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
30
29
|
import { enforceModelAccess } from '../utils/user-permissions';
|
|
30
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
31
31
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
32
32
|
import type PluginAiApiServer from '../plugin';
|
|
33
33
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
@@ -131,7 +131,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
134
|
-
const config = await ctx
|
|
134
|
+
const config = await getAiApiConfig(ctx);
|
|
135
135
|
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
136
136
|
return;
|
|
137
137
|
}
|
|
@@ -166,35 +166,10 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
166
166
|
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
167
167
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
168
168
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// Check role is allowed to use this employee
|
|
173
|
-
if (!checkEmployeeAccess(ctx, config.defaultAiEmployee)) {
|
|
174
|
-
ctx.status = 403;
|
|
175
|
-
ctx.body = toOpenAIError(
|
|
176
|
-
403,
|
|
177
|
-
`Role is not permitted to use AI Employee '${config.defaultAiEmployee}'. ` +
|
|
178
|
-
`An admin must grant access in Settings → Users & Permissions → [Role] → AI API.`,
|
|
179
|
-
'permission_denied',
|
|
180
|
-
'employee_not_permitted',
|
|
181
|
-
);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
185
|
-
filter: { username: config.defaultAiEmployee },
|
|
186
|
-
});
|
|
187
|
-
if (employee) {
|
|
188
|
-
systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ─── Build messages (inject system prompt if not provided by client) ───
|
|
169
|
+
// Direct LLM mode ignores the default AI Employee: its prompt belongs to agent
|
|
170
|
+
// mode only. The model metadata system prompt is still applied later by
|
|
171
|
+
// prepareDirectLlmContext.
|
|
193
172
|
let messages: OpenAIMessage[] = [...body.messages];
|
|
194
|
-
const hasSystemMessage = messages.some((m: any) => m.role === 'system');
|
|
195
|
-
if (systemPrompt && !hasSystemMessage) {
|
|
196
|
-
messages.unshift({ role: 'system', content: systemPrompt });
|
|
197
|
-
}
|
|
198
173
|
|
|
199
174
|
// ─── Process file / file_url blocks through the file processor service ───
|
|
200
175
|
messages = await Promise.all(
|
|
@@ -327,12 +302,14 @@ async function handleNonStreamingCompletion(
|
|
|
327
302
|
|
|
328
303
|
ctx.status = 200;
|
|
329
304
|
const toolCalls = normalizeToolCalls(result.tool_calls);
|
|
305
|
+
const providerFinishReason = extractFinishReason(result);
|
|
330
306
|
ctx.body = toOpenAIResponse({
|
|
331
307
|
id: completionId,
|
|
332
308
|
model: modelName,
|
|
333
309
|
content,
|
|
334
310
|
usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
335
311
|
toolCalls,
|
|
312
|
+
...(providerFinishReason ? { finishReason: providerFinishReason } : {}),
|
|
336
313
|
});
|
|
337
314
|
}
|
|
338
315
|
|
|
@@ -369,8 +346,10 @@ async function handleStreamingCompletion(
|
|
|
369
346
|
|
|
370
347
|
const requestAbort = createRequestAbortController(ctx);
|
|
371
348
|
let usage: Usage | undefined;
|
|
349
|
+
let usageResponseMetadata: unknown;
|
|
372
350
|
let providerRequestId: string | undefined;
|
|
373
|
-
let
|
|
351
|
+
let providerFinishReason: string | undefined;
|
|
352
|
+
let sawToolCalls = false;
|
|
374
353
|
try {
|
|
375
354
|
const stream = await chatModel.stream(messages, { ...providerRequestParameters, signal: requestAbort.signal });
|
|
376
355
|
|
|
@@ -401,7 +380,7 @@ async function handleStreamingCompletion(
|
|
|
401
380
|
const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
|
|
402
381
|
if (toolCallChunks.length) {
|
|
403
382
|
markAiApiFirstProviderOutput(ctx);
|
|
404
|
-
|
|
383
|
+
sawToolCalls = true;
|
|
405
384
|
await writeResponse(
|
|
406
385
|
ctx,
|
|
407
386
|
formatSSE(
|
|
@@ -414,8 +393,14 @@ async function handleStreamingCompletion(
|
|
|
414
393
|
);
|
|
415
394
|
}
|
|
416
395
|
if (chunk.usage_metadata) {
|
|
417
|
-
|
|
396
|
+
const normalized = normalizeUsage(chunk.usage_metadata);
|
|
397
|
+
if (normalized) {
|
|
398
|
+
usage = normalized;
|
|
399
|
+
usageResponseMetadata = chunk.response_metadata;
|
|
400
|
+
}
|
|
418
401
|
}
|
|
402
|
+
const chunkFinishReason = extractFinishReason(chunk);
|
|
403
|
+
if (chunkFinishReason) providerFinishReason = chunkFinishReason;
|
|
419
404
|
providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
|
|
420
405
|
}
|
|
421
406
|
|
|
@@ -427,7 +412,7 @@ async function handleStreamingCompletion(
|
|
|
427
412
|
id: completionId,
|
|
428
413
|
model: modelName,
|
|
429
414
|
delta: {},
|
|
430
|
-
finishReason,
|
|
415
|
+
finishReason: providerFinishReason ?? (sawToolCalls ? 'tool_calls' : 'stop'),
|
|
431
416
|
}),
|
|
432
417
|
),
|
|
433
418
|
);
|
|
@@ -447,7 +432,7 @@ async function handleStreamingCompletion(
|
|
|
447
432
|
|
|
448
433
|
// Send [DONE]
|
|
449
434
|
await writeResponse(ctx, formatSSEDone());
|
|
450
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
435
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
451
436
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
452
437
|
} catch (err) {
|
|
453
438
|
const cancelled = isClientDisconnected(ctx, err);
|
|
@@ -464,7 +449,7 @@ async function handleStreamingCompletion(
|
|
|
464
449
|
}),
|
|
465
450
|
);
|
|
466
451
|
}
|
|
467
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
452
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
468
453
|
ctx.state.aiApiStreamResult = {
|
|
469
454
|
succeeded: false,
|
|
470
455
|
id: completionId,
|
|
@@ -480,6 +465,24 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
480
465
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
481
466
|
}
|
|
482
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Reads the provider's finish_reason from a LangChain result or stream chunk.
|
|
470
|
+
* LangChain surfaces it in response_metadata.finish_reason; some adapters put it
|
|
471
|
+
* in additional_kwargs.finish_reason instead. Returns undefined when the provider
|
|
472
|
+
* reported nothing, so callers can fall back to their own default.
|
|
473
|
+
*/
|
|
474
|
+
export function extractFinishReason(value: unknown): string | undefined {
|
|
475
|
+
if (!isRecord(value)) return undefined;
|
|
476
|
+
const candidates = [
|
|
477
|
+
isRecord(value.response_metadata) ? value.response_metadata.finish_reason : undefined,
|
|
478
|
+
isRecord(value.additional_kwargs) ? value.additional_kwargs.finish_reason : undefined,
|
|
479
|
+
];
|
|
480
|
+
for (const candidate of candidates) {
|
|
481
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
483
486
|
/**
|
|
484
487
|
* Content block types every provider adapter in `@nocobase/plugin-ai` maps to a
|
|
485
488
|
* native equivalent.
|
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
isStreamingRequested,
|
|
24
24
|
writeResponse,
|
|
25
25
|
} from '../utils/streaming';
|
|
26
|
-
import { getProviderRequestParameters, applyProviderRequestParameters } from './chat-completions';
|
|
26
|
+
import { getProviderRequestParameters, applyProviderRequestParameters, extractFinishReason } from './chat-completions';
|
|
27
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
27
28
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
28
29
|
import type PluginAiApiServer from '../plugin';
|
|
29
30
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
@@ -103,7 +104,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
106
|
-
const config = await ctx
|
|
107
|
+
const config = await getAiApiConfig(ctx);
|
|
107
108
|
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
@@ -134,20 +135,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
134
135
|
? body.prompt.join('\n')
|
|
135
136
|
: String(body.prompt);
|
|
136
137
|
|
|
137
|
-
//
|
|
138
|
-
const messages: OpenAIMessage[] = [];
|
|
139
|
-
if (config?.defaultAiEmployee) {
|
|
140
|
-
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
141
|
-
filter: { username: config.defaultAiEmployee },
|
|
142
|
-
});
|
|
143
|
-
if (employee) {
|
|
144
|
-
const systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
145
|
-
if (systemPrompt) {
|
|
146
|
-
messages.push({ role: 'system', content: systemPrompt });
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
messages.push({ role: 'user', content: prompt });
|
|
138
|
+
// Direct LLM mode ignores the default AI Employee: its prompt belongs to agent mode only.
|
|
139
|
+
const messages: OpenAIMessage[] = [{ role: 'user', content: prompt }];
|
|
151
140
|
|
|
152
141
|
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
153
142
|
serviceName: service.name,
|
|
@@ -253,7 +242,7 @@ async function handleNonStreamingTextCompletion(
|
|
|
253
242
|
text,
|
|
254
243
|
index: 0,
|
|
255
244
|
logprobs: null,
|
|
256
|
-
finish_reason: 'stop',
|
|
245
|
+
finish_reason: extractFinishReason(result) ?? 'stop',
|
|
257
246
|
},
|
|
258
247
|
],
|
|
259
248
|
usage: usage
|
|
@@ -288,7 +277,9 @@ async function handleStreamingTextCompletion(
|
|
|
288
277
|
|
|
289
278
|
const requestAbort = createRequestAbortController(ctx);
|
|
290
279
|
let usage: Usage | undefined;
|
|
280
|
+
let usageResponseMetadata: unknown;
|
|
291
281
|
let providerRequestId: string | undefined;
|
|
282
|
+
let providerFinishReason: string | undefined;
|
|
292
283
|
try {
|
|
293
284
|
const stream = await chatModel.stream(messages, {
|
|
294
285
|
...providerRequestParameters,
|
|
@@ -329,8 +320,14 @@ async function handleStreamingTextCompletion(
|
|
|
329
320
|
);
|
|
330
321
|
}
|
|
331
322
|
if (chunk.usage_metadata) {
|
|
332
|
-
|
|
323
|
+
const normalized = normalizeUsage(chunk.usage_metadata);
|
|
324
|
+
if (normalized) {
|
|
325
|
+
usage = normalized;
|
|
326
|
+
usageResponseMetadata = chunk.response_metadata;
|
|
327
|
+
}
|
|
333
328
|
}
|
|
329
|
+
const chunkFinishReason = extractFinishReason(chunk);
|
|
330
|
+
if (chunkFinishReason) providerFinishReason = chunkFinishReason;
|
|
334
331
|
providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
|
|
335
332
|
}
|
|
336
333
|
|
|
@@ -348,7 +345,7 @@ async function handleStreamingTextCompletion(
|
|
|
348
345
|
text: '',
|
|
349
346
|
index: 0,
|
|
350
347
|
logprobs: null,
|
|
351
|
-
finish_reason: 'stop',
|
|
348
|
+
finish_reason: providerFinishReason ?? 'stop',
|
|
352
349
|
},
|
|
353
350
|
],
|
|
354
351
|
usage: null,
|
|
@@ -370,7 +367,7 @@ async function handleStreamingTextCompletion(
|
|
|
370
367
|
}
|
|
371
368
|
|
|
372
369
|
await writeResponse(ctx, formatSSEDone());
|
|
373
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
370
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
374
371
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
375
372
|
} catch (err) {
|
|
376
373
|
const cancelled = isClientDisconnected(ctx, err);
|
|
@@ -386,7 +383,7 @@ async function handleStreamingTextCompletion(
|
|
|
386
383
|
}),
|
|
387
384
|
);
|
|
388
385
|
}
|
|
389
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
386
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
390
387
|
ctx.state.aiApiStreamResult = {
|
|
391
388
|
succeeded: false,
|
|
392
389
|
id: completionId,
|
|
@@ -11,6 +11,7 @@ import { Context } from '@nocobase/actions';
|
|
|
11
11
|
import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
|
|
12
12
|
import { resolveModelString } from '../utils/resolve-service';
|
|
13
13
|
import { enforceModelAccess } from '../utils/user-permissions';
|
|
14
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
14
15
|
import { setAiApiUsageUnavailable } from '../usage';
|
|
15
16
|
import type PluginAiApiServer from '../plugin';
|
|
16
17
|
|
|
@@ -118,7 +119,7 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
118
119
|
// still enforced: an explicit deny must never be bypassed by an unreadable config.
|
|
119
120
|
let globalEnabledServices: unknown = [];
|
|
120
121
|
try {
|
|
121
|
-
const config = await ctx
|
|
122
|
+
const config = await getAiApiConfig(ctx);
|
|
122
123
|
globalEnabledServices = config?.enabledLlmServices ?? [];
|
|
123
124
|
} catch {
|
|
124
125
|
// Config read failure: fail open on the global whitelist only.
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { toOpenAIError } from '../utils/openai-format';
|
|
12
12
|
import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
|
|
13
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
13
14
|
import type PluginAiApiServer from '../plugin';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -20,8 +21,8 @@ import type PluginAiApiServer from '../plugin';
|
|
|
20
21
|
* so clients can copy-paste the ID directly into POST /v1/chat/completions
|
|
21
22
|
* without needing to configure a defaultLlmService.
|
|
22
23
|
*
|
|
23
|
-
* The catalog is scoped to the caller:
|
|
24
|
-
*
|
|
24
|
+
* The catalog is scoped to the caller: it starts from the global whitelist and is
|
|
25
|
+
* narrowed by the caller's usage group settings (allowedLlmServices / allowedModels).
|
|
25
26
|
*
|
|
26
27
|
* Backward compatibility: resolveModelString() in resolve-service.ts still
|
|
27
28
|
* accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
|
|
@@ -251,7 +252,7 @@ function toPositiveInt(value: unknown): number | null {
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
async function getPluginConfig(ctx: Context) {
|
|
254
|
-
return ctx
|
|
255
|
+
return getAiApiConfig(ctx);
|
|
255
256
|
}
|
|
256
257
|
|
|
257
258
|
/**
|
|
@@ -20,6 +20,7 @@ import { createRateLimitMiddleware } from '../middleware/rate-limit';
|
|
|
20
20
|
import { checkRolePermission } from '../middleware/role-permission';
|
|
21
21
|
import { startUsageRecord, finishUsageRecord } from '../usage';
|
|
22
22
|
import { isStreamingRequested } from '../utils/streaming';
|
|
23
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
23
24
|
import type PluginAiApiServer from '../plugin';
|
|
24
25
|
import { finalizeLlmBilling } from '../billing';
|
|
25
26
|
import { finishAiApiObservation, startAiApiObservation } from '../utils/app-observability';
|
|
@@ -43,7 +44,7 @@ type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
|
|
|
43
44
|
* - OPTIONS preflight handling (204)
|
|
44
45
|
* - X-Request-Id on every response
|
|
45
46
|
* - Bearer token authentication
|
|
46
|
-
* - Sliding window rate limiting (enforces rateLimitPerMinute from
|
|
47
|
+
* - Sliding window rate limiting (enforces rateLimitPerMinute from the user's usage group)
|
|
47
48
|
* - Structured request logging via app.logger
|
|
48
49
|
*
|
|
49
50
|
* Supported endpoints:
|
|
@@ -353,7 +354,7 @@ export function normalizeMaxRequestBodyMb(value: unknown): number {
|
|
|
353
354
|
async function resolveMaxBodyBytes(ctx: Context): Promise<number> {
|
|
354
355
|
let configuredMb: unknown;
|
|
355
356
|
try {
|
|
356
|
-
const config = await ctx
|
|
357
|
+
const config = await getAiApiConfig(ctx);
|
|
357
358
|
configuredMb = config?.get('maxRequestBodyMb');
|
|
358
359
|
} catch (err) {
|
|
359
360
|
ctx.log?.warn?.('AI API: could not read maxRequestBodyMb, using default:', err);
|
|
@@ -431,7 +432,7 @@ async function resolveMode(ctx: Context): Promise<'llm' | 'agent'> {
|
|
|
431
432
|
}
|
|
432
433
|
|
|
433
434
|
try {
|
|
434
|
-
const config = await ctx
|
|
435
|
+
const config = await getAiApiConfig(ctx);
|
|
435
436
|
if (config) {
|
|
436
437
|
const dbMode = config.get('mode') || config.mode;
|
|
437
438
|
if (dbMode === 'agent' || dbMode === 'llm') {
|