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
|
@@ -15,6 +15,7 @@ import { handleChatCompletions } from './chat-completions';
|
|
|
15
15
|
import { handleCompletions } from './completions';
|
|
16
16
|
import { handleAgentCompletions } from './agent-completions';
|
|
17
17
|
import { handleEmbeddings } from './embeddings';
|
|
18
|
+
import { handleDeleteResponse, handleGetResponse, handleResponses } from './responses';
|
|
18
19
|
import { toOpenAIError } from '../utils/openai-format';
|
|
19
20
|
import { createRateLimitMiddleware } from '../middleware/rate-limit';
|
|
20
21
|
import { checkRolePermission } from '../middleware/role-permission';
|
|
@@ -51,6 +52,8 @@ type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
|
|
|
51
52
|
* POST /v1/chat/completions — OpenAI chat completions (LLM or agent mode)
|
|
52
53
|
* POST /v1/completions — Legacy text completions (LiteLLM compat)
|
|
53
54
|
* POST /v1/embeddings — OpenAI embeddings
|
|
55
|
+
* POST /v1/responses — OpenAI Responses API
|
|
56
|
+
* GET/DELETE /v1/responses/:id — Retrieve or delete a stored response
|
|
54
57
|
* GET /v1/models — List available models
|
|
55
58
|
* GET /v1/models/:id — Get a single model
|
|
56
59
|
* DELETE /v1/models/:id — Not implemented (501 stub)
|
|
@@ -148,11 +151,18 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
148
151
|
const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
|
|
149
152
|
const model = requestBody.model === undefined || requestBody.model === null ? '-' : String(requestBody.model);
|
|
150
153
|
const isUsageEndpoint =
|
|
151
|
-
method === 'POST' &&
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
method === 'POST' &&
|
|
155
|
+
(subPath === '/chat/completions' ||
|
|
156
|
+
subPath === '/completions' ||
|
|
157
|
+
subPath === '/embeddings' ||
|
|
158
|
+
subPath === '/responses');
|
|
159
|
+
const isStreamingEndpoint =
|
|
160
|
+
method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions' || subPath === '/responses');
|
|
161
|
+
const resolvedMode = subPath === '/responses' ? 'llm' : isUsageEndpoint ? await resolveMode(ctx) : 'llm';
|
|
162
|
+
const streaming =
|
|
163
|
+
isStreamingEndpoint &&
|
|
164
|
+
(subPath === '/responses' ? requestBody.stream === true : isStreamingRequested(requestBody.stream));
|
|
165
|
+
if (streaming && subPath !== '/responses') {
|
|
156
166
|
const streamOptions = requestBody.stream_options;
|
|
157
167
|
ctx.request.body = {
|
|
158
168
|
...requestBody,
|
|
@@ -167,11 +177,13 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
167
177
|
const service =
|
|
168
178
|
subPath === '/embeddings'
|
|
169
179
|
? 'llm.embedding'
|
|
170
|
-
:
|
|
171
|
-
? 'llm.
|
|
172
|
-
:
|
|
173
|
-
? 'llm.
|
|
174
|
-
: '
|
|
180
|
+
: subPath === '/responses'
|
|
181
|
+
? 'llm.responses'
|
|
182
|
+
: resolvedMode === 'agent'
|
|
183
|
+
? 'llm.agent'
|
|
184
|
+
: subPath === '/completions'
|
|
185
|
+
? 'llm.completion'
|
|
186
|
+
: 'llm.chat';
|
|
175
187
|
startAiApiObservation(ctx, {
|
|
176
188
|
service,
|
|
177
189
|
operation: subPath,
|
|
@@ -210,6 +222,54 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
210
222
|
return;
|
|
211
223
|
}
|
|
212
224
|
|
|
225
|
+
// POST /v1/responses — OpenAI Responses API
|
|
226
|
+
if (method === 'POST' && subPath === '/responses') {
|
|
227
|
+
await handleResponses(ctx, plugin);
|
|
228
|
+
logRequest(
|
|
229
|
+
ctx,
|
|
230
|
+
requestId,
|
|
231
|
+
model,
|
|
232
|
+
ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
|
|
233
|
+
Date.now() - t0,
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// GET/DELETE /v1/responses/:id — stored Responses API objects, scoped to the authenticated user
|
|
239
|
+
if ((method === 'GET' || method === 'DELETE') && subPath.startsWith('/responses/')) {
|
|
240
|
+
const responseId = subPath.substring('/responses/'.length);
|
|
241
|
+
if (responseId) {
|
|
242
|
+
let decodedResponseId: string;
|
|
243
|
+
try {
|
|
244
|
+
decodedResponseId = decodeURIComponent(responseId);
|
|
245
|
+
} catch {
|
|
246
|
+
ctx.status = 400;
|
|
247
|
+
ctx.body = toOpenAIError(
|
|
248
|
+
400,
|
|
249
|
+
'Response ID contains invalid URL encoding',
|
|
250
|
+
'invalid_request_error',
|
|
251
|
+
'invalid_response_id',
|
|
252
|
+
);
|
|
253
|
+
logRequest(ctx, requestId, '-', 'invalid_response_id', Date.now() - t0);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (decodedResponseId.includes('/')) {
|
|
257
|
+
ctx.status = 404;
|
|
258
|
+
ctx.body = toOpenAIError(
|
|
259
|
+
404,
|
|
260
|
+
`Unknown endpoint: ${method} ${path}`,
|
|
261
|
+
'invalid_request_error',
|
|
262
|
+
'unknown_url',
|
|
263
|
+
);
|
|
264
|
+
logRequest(ctx, requestId, '-', 'not_found', Date.now() - t0);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (method === 'GET') await handleGetResponse(ctx, decodedResponseId);
|
|
268
|
+
else await handleDeleteResponse(ctx, decodedResponseId);
|
|
269
|
+
logRequest(ctx, requestId, '-', ctx.status === 404 ? 'not_found' : 'ok', Date.now() - t0);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
213
273
|
// POST /v1/completions (legacy text completions — used by LiteLLM)
|
|
214
274
|
if (method === 'POST' && subPath === '/completions') {
|
|
215
275
|
const completionsMode = resolvedMode;
|
|
@@ -250,7 +310,16 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
250
310
|
if (method === 'GET' && subPath.startsWith('/models/')) {
|
|
251
311
|
const modelId = subPath.substring('/models/'.length);
|
|
252
312
|
if (modelId) {
|
|
253
|
-
|
|
313
|
+
let decodedModelId: string;
|
|
314
|
+
try {
|
|
315
|
+
decodedModelId = decodeURIComponent(modelId);
|
|
316
|
+
} catch {
|
|
317
|
+
ctx.status = 400;
|
|
318
|
+
ctx.body = toOpenAIError(400, 'Model ID contains invalid URL encoding', 'invalid_request_error');
|
|
319
|
+
logRequest(ctx, requestId, modelId, 'bad_request', Date.now() - t0);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
await handleGetModel(ctx, decodedModelId, plugin);
|
|
254
323
|
logRequest(ctx, requestId, modelId, 'ok', Date.now() - t0);
|
|
255
324
|
return;
|
|
256
325
|
}
|
|
@@ -275,7 +344,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
275
344
|
ctx.body = toOpenAIError(
|
|
276
345
|
404,
|
|
277
346
|
`Unknown endpoint: ${method} ${path}. ` +
|
|
278
|
-
`Supported: POST /v1/chat/completions, POST /v1/completions, POST /v1/embeddings, GET /v1/models`,
|
|
347
|
+
`Supported: POST /v1/chat/completions, POST /v1/completions, POST /v1/embeddings, POST /v1/responses, GET/DELETE /v1/responses/:id, GET /v1/models`,
|
|
279
348
|
'invalid_request_error',
|
|
280
349
|
'unknown_url',
|
|
281
350
|
);
|
package/src/server/usage.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type Usage = {
|
|
|
7
7
|
completion_tokens: number | null;
|
|
8
8
|
total_tokens: number | null;
|
|
9
9
|
prompt_cache_tokens?: number | null;
|
|
10
|
+
reasoning_tokens?: number | null;
|
|
10
11
|
};
|
|
11
12
|
|
|
12
13
|
export type AiApiAuthType = 'apiKey' | 'bearer' | 'oidc' | 'unknown';
|
|
@@ -39,6 +40,8 @@ interface AiApiContextState {
|
|
|
39
40
|
currentUser?: { id?: string | number | bigint };
|
|
40
41
|
oauthPrincipal?: OAuthPrincipal;
|
|
41
42
|
aiApiLlmBilling?: LlmBillingState;
|
|
43
|
+
aiApiVirtualModel?: string;
|
|
44
|
+
aiApiRoutingReason?: string;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
function getAiApiState(ctx: Context): AiApiContextState {
|
|
@@ -76,6 +79,21 @@ function extractPromptCacheTokens(source: Record<string, unknown>): number | nul
|
|
|
76
79
|
return null;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
function extractReasoningTokens(source: Record<string, unknown>): number | null {
|
|
83
|
+
const detailCandidates = [
|
|
84
|
+
source.output_token_details,
|
|
85
|
+
source.output_tokens_details,
|
|
86
|
+
source.completion_tokens_details,
|
|
87
|
+
];
|
|
88
|
+
for (const details of detailCandidates) {
|
|
89
|
+
if (!details || typeof details !== 'object') continue;
|
|
90
|
+
const record = details as Record<string, unknown>;
|
|
91
|
+
const value = normalizeTokenCount(record.reasoning ?? record.reasoning_tokens);
|
|
92
|
+
if (value !== null) return value;
|
|
93
|
+
}
|
|
94
|
+
return normalizeTokenCount(source.reasoning_tokens ?? source.reasoningTokens);
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
export function normalizeUsage(value: unknown): Usage | undefined {
|
|
80
98
|
if (!value || typeof value !== 'object') return undefined;
|
|
81
99
|
const source = value as Record<string, unknown>;
|
|
@@ -97,6 +115,7 @@ export function normalizeUsage(value: unknown): Usage | undefined {
|
|
|
97
115
|
// usage once and hand the result to setAiApiUsageResult, which normalizes
|
|
98
116
|
// again — an already-extracted prompt_cache_tokens must survive that pass.
|
|
99
117
|
prompt_cache_tokens: extractPromptCacheTokens(source) ?? normalizeTokenCount(source.prompt_cache_tokens),
|
|
118
|
+
reasoning_tokens: extractReasoningTokens(source) ?? normalizeTokenCount(source.reasoning_tokens),
|
|
100
119
|
};
|
|
101
120
|
}
|
|
102
121
|
|
|
@@ -180,7 +199,11 @@ export async function startUsageRecord(
|
|
|
180
199
|
values: {
|
|
181
200
|
requestId,
|
|
182
201
|
userId,
|
|
183
|
-
|
|
202
|
+
// When several roles granted access (union semantics), record all of them so the
|
|
203
|
+
// audit trail shows the full set, not just the first role in the list.
|
|
204
|
+
roleName: state.currentRoles?.length
|
|
205
|
+
? (state.currentRoles as string[]).join(',')
|
|
206
|
+
: state.currentRole || 'unknown',
|
|
184
207
|
authType: state.aiApiAuthType || (oauth ? 'oidc' : 'unknown'),
|
|
185
208
|
oauthClientId: oauth?.clientId,
|
|
186
209
|
oauthSubject: oauth?.subject,
|
|
@@ -195,7 +218,7 @@ export async function startUsageRecord(
|
|
|
195
218
|
messageCount: messages?.length,
|
|
196
219
|
promptCount,
|
|
197
220
|
embeddingInputCount,
|
|
198
|
-
requestedMaxTokens: body.max_completion_tokens ?? body.max_tokens,
|
|
221
|
+
requestedMaxTokens: body.max_output_tokens ?? body.max_completion_tokens ?? body.max_tokens,
|
|
199
222
|
},
|
|
200
223
|
},
|
|
201
224
|
});
|
|
@@ -242,6 +265,10 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
|
|
|
242
265
|
responseMetadata: {
|
|
243
266
|
usageSource: usageResult.source,
|
|
244
267
|
...(gatewayResponseId ? { gatewayResponseId } : {}),
|
|
268
|
+
// When the request went through a virtual alias, keep both the alias and why
|
|
269
|
+
// it routed there; resolvedModel (from billing state) already holds the concrete model.
|
|
270
|
+
...(state.aiApiVirtualModel ? { virtualModel: state.aiApiVirtualModel } : {}),
|
|
271
|
+
...(state.aiApiRoutingReason ? { routingReason: state.aiApiRoutingReason } : {}),
|
|
245
272
|
},
|
|
246
273
|
};
|
|
247
274
|
await ctx.db.getRepository('aiApiUsageRecords').update({ filterByTk: id, values });
|
|
@@ -48,7 +48,7 @@ function safely(ctx: Context, callback: () => void): void {
|
|
|
48
48
|
export function startAiApiObservation(
|
|
49
49
|
ctx: Context,
|
|
50
50
|
input: {
|
|
51
|
-
service: 'llm.chat' | 'llm.agent' | 'llm.completion' | 'llm.embedding';
|
|
51
|
+
service: 'llm.chat' | 'llm.agent' | 'llm.completion' | 'llm.embedding' | 'llm.responses';
|
|
52
52
|
operation: string;
|
|
53
53
|
streaming: boolean;
|
|
54
54
|
model?: string;
|
|
@@ -24,6 +24,7 @@ interface ContextPreparationOptions {
|
|
|
24
24
|
tools?: unknown;
|
|
25
25
|
maxCompletionTokens?: unknown;
|
|
26
26
|
maxTokens?: unknown;
|
|
27
|
+
overflowBehavior?: ContextOverflowBehavior;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface PreparedDirectLlmContext {
|
|
@@ -348,7 +349,7 @@ export async function prepareDirectLlmContext(
|
|
|
348
349
|
): Promise<PreparedDirectLlmContext> {
|
|
349
350
|
const [metadata, behavior] = await Promise.all([
|
|
350
351
|
loadModelMetadata(ctx, options.serviceName, options.modelId),
|
|
351
|
-
resolveOverflowBehavior(ctx),
|
|
352
|
+
options.overflowBehavior ? Promise.resolve(options.overflowBehavior) : resolveOverflowBehavior(ctx),
|
|
352
353
|
]);
|
|
353
354
|
const reservedOutputTokens = resolveReservedOutputTokens(options, metadata);
|
|
354
355
|
const inputTokenBudget = metadata.contextWindow - reservedOutputTokens;
|
|
@@ -117,6 +117,7 @@ export type OpenAIUsage = {
|
|
|
117
117
|
completion_tokens: number | null;
|
|
118
118
|
total_tokens: number | null;
|
|
119
119
|
prompt_cache_tokens?: number | null;
|
|
120
|
+
reasoning_tokens?: number | null;
|
|
120
121
|
};
|
|
121
122
|
|
|
122
123
|
export type OpenAIStreamObject = 'chat.completion.chunk' | 'text_completion';
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
|
+
import type { Model } from '@nocobase/database';
|
|
11
12
|
import { getAiApiConfig } from './request-cache';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -36,7 +37,7 @@ export async function resolveLlmService(ctx: Context, serviceKey: string) {
|
|
|
36
37
|
export async function resolveModelString(
|
|
37
38
|
ctx: Context,
|
|
38
39
|
modelString: string,
|
|
39
|
-
): Promise<{ service:
|
|
40
|
+
): Promise<{ service: Model; modelId: string } | null> {
|
|
40
41
|
const repo = ctx.db.getRepository('llmServices');
|
|
41
42
|
|
|
42
43
|
// ─── Strategy 1: Try splitting at "/" positions ───
|
|
@@ -81,3 +82,40 @@ export async function resolveModelString(
|
|
|
81
82
|
|
|
82
83
|
return null;
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a configured model reference strictly. Unlike resolveModelString, this never falls
|
|
87
|
+
* back to the default or single enabled service: a reference like "missing-service/gpt-4o"
|
|
88
|
+
* must fail instead of being reinterpreted as a model id on an unrelated service. Use this for
|
|
89
|
+
* admin-configured references (virtual model buckets and fallbacks), where silent misrouting
|
|
90
|
+
* would route traffic to the wrong model.
|
|
91
|
+
*/
|
|
92
|
+
export async function resolveModelReference(
|
|
93
|
+
ctx: Context,
|
|
94
|
+
reference: string,
|
|
95
|
+
): Promise<{ service: Model; modelId: string } | null> {
|
|
96
|
+
const repo = ctx.db.getRepository('llmServices');
|
|
97
|
+
|
|
98
|
+
const slashPositions: number[] = [];
|
|
99
|
+
for (let i = 0; i < reference.length; i++) {
|
|
100
|
+
if (reference[i] === '/') {
|
|
101
|
+
slashPositions.push(i);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const pos of slashPositions) {
|
|
106
|
+
const serviceKey = reference.substring(0, pos);
|
|
107
|
+
const modelId = reference.substring(pos + 1);
|
|
108
|
+
if (!serviceKey || !modelId) continue;
|
|
109
|
+
|
|
110
|
+
let service = await repo.findOne({ filter: { name: serviceKey } });
|
|
111
|
+
if (!service) {
|
|
112
|
+
service = await repo.findOne({ filter: { title: serviceKey } });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (service) {
|
|
116
|
+
return { service, modelId };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 type { Context } from '@nocobase/actions';
|
|
11
|
+
import type { Model } from '@nocobase/database';
|
|
12
|
+
import type { OpenAIMessage } from './direct-llm-context';
|
|
13
|
+
import { responsesInputToMessages, type ResponseObject } from './responses-format';
|
|
14
|
+
|
|
15
|
+
const RETENTION_DAYS = 30;
|
|
16
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
17
|
+
export const RESPONSE_RETENTION_MS = RETENTION_DAYS * MS_PER_DAY;
|
|
18
|
+
|
|
19
|
+
function valueOf<T>(model: unknown, key: string): T | undefined {
|
|
20
|
+
if (!model) return undefined;
|
|
21
|
+
if (typeof (model as { get?: unknown }).get === 'function') {
|
|
22
|
+
return (model as { get: (k: string) => unknown }).get(key) as T | undefined;
|
|
23
|
+
}
|
|
24
|
+
return (model as Record<string, unknown>)[key] as T | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isExpired(value: unknown): boolean {
|
|
28
|
+
if (value === undefined || value === null || value === '') return true;
|
|
29
|
+
const timestamp = value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
|
|
30
|
+
return !Number.isFinite(timestamp) || timestamp <= Date.now();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResponseRecord {
|
|
34
|
+
id: string | number | bigint;
|
|
35
|
+
responseId: string;
|
|
36
|
+
userId: string | number | bigint;
|
|
37
|
+
model: string;
|
|
38
|
+
input: unknown;
|
|
39
|
+
output: ResponseObject;
|
|
40
|
+
previousResponseId?: string;
|
|
41
|
+
metadata?: Record<string, unknown>;
|
|
42
|
+
expiresAt: Date | string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function storeResponseRecord(
|
|
46
|
+
ctx: Context,
|
|
47
|
+
response: ResponseObject,
|
|
48
|
+
requestBody: Record<string, unknown>,
|
|
49
|
+
userId: string | number | bigint,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
if (requestBody.store === false) return;
|
|
52
|
+
|
|
53
|
+
await ctx.db.getRepository('aiApiResponseRecords').create({
|
|
54
|
+
values: {
|
|
55
|
+
responseId: response.id,
|
|
56
|
+
userId,
|
|
57
|
+
model: response.model,
|
|
58
|
+
input: requestBody.input,
|
|
59
|
+
output: response,
|
|
60
|
+
previousResponseId: response.previous_response_id,
|
|
61
|
+
metadata: response.metadata,
|
|
62
|
+
expiresAt: new Date(Date.now() + RESPONSE_RETENTION_MS),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function getResponseRecord(
|
|
68
|
+
ctx: Pick<Context, 'db'>,
|
|
69
|
+
responseId: string,
|
|
70
|
+
userId: string | number | bigint,
|
|
71
|
+
): Promise<ResponseRecord | null> {
|
|
72
|
+
const row = await ctx.db.getRepository('aiApiResponseRecords').findOne({
|
|
73
|
+
filter: { responseId, userId },
|
|
74
|
+
});
|
|
75
|
+
if (!row || isExpired(valueOf(row, 'expiresAt'))) return null;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id: valueOf(row, 'id') as string | number | bigint,
|
|
79
|
+
responseId: valueOf<string>(row, 'responseId') as string,
|
|
80
|
+
userId: valueOf(row, 'userId') as string | number | bigint,
|
|
81
|
+
model: valueOf<string>(row, 'model') as string,
|
|
82
|
+
input: valueOf(row, 'input'),
|
|
83
|
+
output: valueOf<ResponseObject>(row, 'output') as ResponseObject,
|
|
84
|
+
previousResponseId: valueOf<string>(row, 'previousResponseId') ?? undefined,
|
|
85
|
+
metadata: valueOf<Record<string, unknown>>(row, 'metadata'),
|
|
86
|
+
expiresAt: valueOf<Date | string>(row, 'expiresAt') as Date | string,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function responseOutputToMessages(output: ResponseObject): OpenAIMessage[] {
|
|
91
|
+
return responsesInputToMessages(output.output);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function loadConversationChain(
|
|
95
|
+
ctx: Context,
|
|
96
|
+
responseId: string,
|
|
97
|
+
userId: string | number | bigint,
|
|
98
|
+
maxDepth = 100,
|
|
99
|
+
): Promise<OpenAIMessage[] | null> {
|
|
100
|
+
const records: ResponseRecord[] = [];
|
|
101
|
+
const visited = new Set<string>();
|
|
102
|
+
let currentId: string | undefined = responseId;
|
|
103
|
+
|
|
104
|
+
while (currentId) {
|
|
105
|
+
if (records.length >= maxDepth || visited.has(currentId)) return null;
|
|
106
|
+
visited.add(currentId);
|
|
107
|
+
const record = await getResponseRecord(ctx, currentId, userId);
|
|
108
|
+
if (!record) return null;
|
|
109
|
+
records.unshift(record);
|
|
110
|
+
currentId = record.previousResponseId;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return records.flatMap((record) => [
|
|
114
|
+
...responsesInputToMessages(record.input),
|
|
115
|
+
...responseOutputToMessages(record.output),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function cleanupExpiredResponseRecords(ctx: Pick<Context, 'db'>): Promise<number> {
|
|
120
|
+
const repo = ctx.db.getRepository('aiApiResponseRecords');
|
|
121
|
+
const now = new Date();
|
|
122
|
+
let deleted = 0;
|
|
123
|
+
for (;;) {
|
|
124
|
+
const expired = (await repo.find({
|
|
125
|
+
filter: { expiresAt: { $lt: now } },
|
|
126
|
+
fields: ['id'],
|
|
127
|
+
limit: 1000,
|
|
128
|
+
sort: 'id',
|
|
129
|
+
})) as Model[];
|
|
130
|
+
const ids = expired.map((record) => record.get('id'));
|
|
131
|
+
if (ids.length === 0) return deleted;
|
|
132
|
+
const count = await repo.destroy({ filterByTk: ids, individualHooks: false });
|
|
133
|
+
if (typeof count === 'number') deleted += count;
|
|
134
|
+
else deleted += ids.length;
|
|
135
|
+
if (count === 0) return deleted;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function deleteResponseRecord(
|
|
140
|
+
ctx: Pick<Context, 'db'>,
|
|
141
|
+
responseId: string,
|
|
142
|
+
userId: string | number | bigint,
|
|
143
|
+
): Promise<boolean> {
|
|
144
|
+
const record = await getResponseRecord(ctx, responseId, userId);
|
|
145
|
+
if (!record) return false;
|
|
146
|
+
await ctx.db.getRepository('aiApiResponseRecords').destroy({ filterByTk: record.id });
|
|
147
|
+
return true;
|
|
148
|
+
}
|