plugin-ai-api 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/97.1bc5103fd9d995a8.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/97.96da323832251796.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +38 -1
- package/dist/locale/vi-VN.json +177 -138
- package/dist/locale/zh-CN.json +177 -138
- package/dist/server/billing.js +17 -9
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-model-prices.js +8 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/auth.js +16 -0
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +34 -5
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +26 -4
- package/dist/server/utils/direct-llm-context.js +10 -9
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +45 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/index.tsx +10 -10
- package/src/client/models/index.ts +12 -12
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +38 -1
- package/src/locale/vi-VN.json +177 -138
- package/src/locale/zh-CN.json +177 -138
- package/src/server/__tests__/billing-quota.test.ts +8 -2
- package/src/server/__tests__/billing.test.ts +13 -0
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +15 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/billing.ts +29 -6
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-model-prices.ts +8 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/auth.ts +21 -1
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +42 -6
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +30 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +23 -12
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +50 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
|
@@ -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
|
});
|
|
@@ -233,6 +256,7 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
|
|
|
233
256
|
quotaPolicyId: billing.quotaPolicyId ?? null,
|
|
234
257
|
groupId: billing.groupId ?? null,
|
|
235
258
|
inputPricePerMillionTokens: billing.inputPricePerMillionTokens ?? null,
|
|
259
|
+
cacheInputPricePerMillionTokens: billing.cacheInputPricePerMillionTokens ?? null,
|
|
236
260
|
outputPricePerMillionTokens: billing.outputPricePerMillionTokens ?? null,
|
|
237
261
|
fixedCostPerRequest: billing.fixedCostPerRequest ?? null,
|
|
238
262
|
providerRequestId: usageResult.providerRequestId ?? null,
|
|
@@ -241,6 +265,10 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
|
|
|
241
265
|
responseMetadata: {
|
|
242
266
|
usageSource: usageResult.source,
|
|
243
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 } : {}),
|
|
244
272
|
},
|
|
245
273
|
};
|
|
246
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;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context } from '@nocobase/actions';
|
|
2
|
-
import { resolveRequestUserGroup } from './request-cache';
|
|
2
|
+
import { getAiApiConfig, resolveRequestUserGroup } from './request-cache';
|
|
3
3
|
|
|
4
4
|
export type ContextOverflowBehavior = 'reject' | 'truncate';
|
|
5
5
|
|
|
@@ -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 {
|
|
@@ -39,8 +40,7 @@ export class DirectLlmContextError extends Error {
|
|
|
39
40
|
readonly code:
|
|
40
41
|
| 'context_length_exceeded'
|
|
41
42
|
| 'context_estimation_unsupported'
|
|
42
|
-
| 'max_completion_tokens_exceeds_model_limit'
|
|
43
|
-
| 'model_context_metadata_not_configured',
|
|
43
|
+
| 'max_completion_tokens_exceeds_model_limit',
|
|
44
44
|
message: string,
|
|
45
45
|
) {
|
|
46
46
|
super(message);
|
|
@@ -297,18 +297,29 @@ function messagesWithTurns(messages: OpenAIMessage[], turns: OpenAIMessage[][]):
|
|
|
297
297
|
return messages.filter((message) => isInstruction(message) || retainedMessages.has(message));
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Gateway-wide context budget used when a model has no metadata override (or a
|
|
302
|
+
* partial one). Direct LLM mode must work out of the box for every model; the
|
|
303
|
+
* metadata table is an override mechanism, not a prerequisite. Providers still
|
|
304
|
+
* reject requests that exceed their real window, and admins can tighten the
|
|
305
|
+
* budget per model by adding a metadata row.
|
|
306
|
+
*/
|
|
307
|
+
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
308
|
+
export const DEFAULT_MAX_COMPLETION_TOKENS = 8_192;
|
|
309
|
+
|
|
300
310
|
async function loadModelMetadata(ctx: Context, serviceName: string, modelId: string): Promise<ModelMetadata> {
|
|
301
311
|
const row = await ctx.db.getRepository('aiApiModelMetadata').findOne({
|
|
302
312
|
filter: { llmService: serviceName, model: modelId, enabled: true },
|
|
303
313
|
});
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
)
|
|
311
|
-
|
|
314
|
+
const config = await getAiApiConfig(ctx);
|
|
315
|
+
const contextWindow =
|
|
316
|
+
positiveInteger(getValue<unknown>(row, 'contextWindow')) ??
|
|
317
|
+
positiveInteger(getValue<unknown>(config, 'defaultContextWindow')) ??
|
|
318
|
+
DEFAULT_CONTEXT_WINDOW;
|
|
319
|
+
const maxCompletionTokens =
|
|
320
|
+
positiveInteger(getValue<unknown>(row, 'maxCompletionTokens')) ??
|
|
321
|
+
positiveInteger(getValue<unknown>(config, 'defaultMaxCompletionTokens')) ??
|
|
322
|
+
DEFAULT_MAX_COMPLETION_TOKENS;
|
|
312
323
|
const systemPromptValue = getValue<unknown>(row, 'systemPrompt');
|
|
313
324
|
const systemPrompt = typeof systemPromptValue === 'string' ? systemPromptValue.trim() : '';
|
|
314
325
|
return { contextWindow, maxCompletionTokens, ...(systemPrompt ? { systemPrompt } : {}) };
|
|
@@ -338,7 +349,7 @@ export async function prepareDirectLlmContext(
|
|
|
338
349
|
): Promise<PreparedDirectLlmContext> {
|
|
339
350
|
const [metadata, behavior] = await Promise.all([
|
|
340
351
|
loadModelMetadata(ctx, options.serviceName, options.modelId),
|
|
341
|
-
resolveOverflowBehavior(ctx),
|
|
352
|
+
options.overflowBehavior ? Promise.resolve(options.overflowBehavior) : resolveOverflowBehavior(ctx),
|
|
342
353
|
]);
|
|
343
354
|
const reservedOutputTokens = resolveReservedOutputTokens(options, metadata);
|
|
344
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
|
+
}
|