plugin-ai-api 1.0.24 → 1.0.28
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.6568d3504ad29352.js +10 -0
- 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.f2bc9cfba07004b0.js +10 -0
- 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/externalVersion.js +8 -8
- package/dist/locale/en-US.json +27 -8
- package/dist/locale/vi-VN.json +27 -8
- package/dist/locale/zh-CN.json +27 -8
- package/dist/server/billing.js +31 -33
- package/dist/server/collections/ai-api-config.js +7 -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 +2 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- 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 +100 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +5 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +121 -42
- package/dist/server/routes/completions.js +48 -29
- 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 +426 -0
- package/dist/server/usage.js +37 -3
- package/dist/server/utils/direct-llm-context.js +163 -26
- package/dist/server/utils/openai-format.js +21 -2
- 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 +48 -10
- 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/pages/UsagePage.tsx +9 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +27 -8
- package/src/locale/vi-VN.json +27 -8
- package/src/locale/zh-CN.json +27 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +209 -10
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/request-body.test.ts +45 -2
- 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 +382 -5
- package/src/server/__tests__/usage.test.ts +57 -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 +36 -39
- package/src/server/collections/ai-api-config.ts +9 -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-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +2 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +68 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- 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 +121 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +5 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +173 -47
- package/src/server/routes/completions.ts +50 -27
- 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/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +513 -0
- package/src/server/usage.ts +51 -1
- package/src/server/utils/direct-llm-context.ts +218 -31
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +83 -82
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +52 -11
- 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/757.a01403fb7a1bea01.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/757.a117ce1cf7119cea.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
|
@@ -26,13 +26,14 @@ 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';
|
|
34
34
|
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
35
35
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
36
|
+
import { FileContentBlock, FileProcessorError } from '../services/file-processor';
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* POST /api/ai-llm/v1/chat/completions
|
|
@@ -130,7 +131,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
133
|
-
const config = await ctx
|
|
134
|
+
const config = await getAiApiConfig(ctx);
|
|
134
135
|
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
@@ -165,35 +166,18 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
165
166
|
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
166
167
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
167
168
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Check role is allowed to use this employee
|
|
172
|
-
if (!checkEmployeeAccess(ctx, config.defaultAiEmployee)) {
|
|
173
|
-
ctx.status = 403;
|
|
174
|
-
ctx.body = toOpenAIError(
|
|
175
|
-
403,
|
|
176
|
-
`Role is not permitted to use AI Employee '${config.defaultAiEmployee}'. ` +
|
|
177
|
-
`An admin must grant access in Settings → Users & Permissions → [Role] → AI API.`,
|
|
178
|
-
'permission_denied',
|
|
179
|
-
'employee_not_permitted',
|
|
180
|
-
);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
184
|
-
filter: { username: config.defaultAiEmployee },
|
|
185
|
-
});
|
|
186
|
-
if (employee) {
|
|
187
|
-
systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ─── 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.
|
|
192
172
|
let messages: OpenAIMessage[] = [...body.messages];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
173
|
+
|
|
174
|
+
// ─── Process file / file_url blocks through the file processor service ───
|
|
175
|
+
messages = await Promise.all(
|
|
176
|
+
messages.map(async (msg) => ({
|
|
177
|
+
...msg,
|
|
178
|
+
content: await processMessageContentFileBlocks(msg.content, ctx, plugin),
|
|
179
|
+
})),
|
|
180
|
+
);
|
|
197
181
|
|
|
198
182
|
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
199
183
|
serviceName: service.name,
|
|
@@ -271,13 +255,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
271
255
|
if (!ctx.res?.headersSent) {
|
|
272
256
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
273
257
|
const isContextError = err instanceof DirectLlmContextError;
|
|
274
|
-
|
|
258
|
+
const isFileError = err instanceof FileProcessorError;
|
|
259
|
+
ctx.status = isQuotaError ? 429 : isContextError || isFileError ? 400 : 500;
|
|
275
260
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
276
261
|
ctx.body = toOpenAIError(
|
|
277
262
|
ctx.status,
|
|
278
263
|
getErrorMessage(err, 'Internal server error'),
|
|
279
|
-
isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
|
|
280
|
-
isQuotaError || isContextError ? err.code : undefined,
|
|
264
|
+
isQuotaError ? 'quota_error' : isContextError || isFileError ? 'invalid_request_error' : 'server_error',
|
|
265
|
+
isQuotaError || isContextError || isFileError ? err.code : undefined,
|
|
281
266
|
);
|
|
282
267
|
}
|
|
283
268
|
}
|
|
@@ -305,19 +290,26 @@ async function handleNonStreamingCompletion(
|
|
|
305
290
|
}
|
|
306
291
|
|
|
307
292
|
// Extract usage if available
|
|
308
|
-
const usage = setAiApiUsageResult(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
293
|
+
const usage = setAiApiUsageResult(
|
|
294
|
+
ctx,
|
|
295
|
+
result.usage_metadata,
|
|
296
|
+
{
|
|
297
|
+
gatewayResponseId: completionId,
|
|
298
|
+
providerRequestId: extractProviderRequestId(result),
|
|
299
|
+
},
|
|
300
|
+
result.response_metadata,
|
|
301
|
+
);
|
|
312
302
|
|
|
313
303
|
ctx.status = 200;
|
|
314
304
|
const toolCalls = normalizeToolCalls(result.tool_calls);
|
|
305
|
+
const providerFinishReason = extractFinishReason(result);
|
|
315
306
|
ctx.body = toOpenAIResponse({
|
|
316
307
|
id: completionId,
|
|
317
308
|
model: modelName,
|
|
318
309
|
content,
|
|
319
310
|
usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
320
311
|
toolCalls,
|
|
312
|
+
...(providerFinishReason ? { finishReason: providerFinishReason } : {}),
|
|
321
313
|
});
|
|
322
314
|
}
|
|
323
315
|
|
|
@@ -354,8 +346,10 @@ async function handleStreamingCompletion(
|
|
|
354
346
|
|
|
355
347
|
const requestAbort = createRequestAbortController(ctx);
|
|
356
348
|
let usage: Usage | undefined;
|
|
349
|
+
let usageResponseMetadata: unknown;
|
|
357
350
|
let providerRequestId: string | undefined;
|
|
358
|
-
let
|
|
351
|
+
let providerFinishReason: string | undefined;
|
|
352
|
+
let sawToolCalls = false;
|
|
359
353
|
try {
|
|
360
354
|
const stream = await chatModel.stream(messages, { ...providerRequestParameters, signal: requestAbort.signal });
|
|
361
355
|
|
|
@@ -386,7 +380,7 @@ async function handleStreamingCompletion(
|
|
|
386
380
|
const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
|
|
387
381
|
if (toolCallChunks.length) {
|
|
388
382
|
markAiApiFirstProviderOutput(ctx);
|
|
389
|
-
|
|
383
|
+
sawToolCalls = true;
|
|
390
384
|
await writeResponse(
|
|
391
385
|
ctx,
|
|
392
386
|
formatSSE(
|
|
@@ -399,8 +393,14 @@ async function handleStreamingCompletion(
|
|
|
399
393
|
);
|
|
400
394
|
}
|
|
401
395
|
if (chunk.usage_metadata) {
|
|
402
|
-
|
|
396
|
+
const normalized = normalizeUsage(chunk.usage_metadata);
|
|
397
|
+
if (normalized) {
|
|
398
|
+
usage = normalized;
|
|
399
|
+
usageResponseMetadata = chunk.response_metadata;
|
|
400
|
+
}
|
|
403
401
|
}
|
|
402
|
+
const chunkFinishReason = extractFinishReason(chunk);
|
|
403
|
+
if (chunkFinishReason) providerFinishReason = chunkFinishReason;
|
|
404
404
|
providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
|
|
405
405
|
}
|
|
406
406
|
|
|
@@ -412,7 +412,7 @@ async function handleStreamingCompletion(
|
|
|
412
412
|
id: completionId,
|
|
413
413
|
model: modelName,
|
|
414
414
|
delta: {},
|
|
415
|
-
finishReason,
|
|
415
|
+
finishReason: providerFinishReason ?? (sawToolCalls ? 'tool_calls' : 'stop'),
|
|
416
416
|
}),
|
|
417
417
|
),
|
|
418
418
|
);
|
|
@@ -432,7 +432,7 @@ async function handleStreamingCompletion(
|
|
|
432
432
|
|
|
433
433
|
// Send [DONE]
|
|
434
434
|
await writeResponse(ctx, formatSSEDone());
|
|
435
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
435
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
436
436
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
437
437
|
} catch (err) {
|
|
438
438
|
const cancelled = isClientDisconnected(ctx, err);
|
|
@@ -449,7 +449,7 @@ async function handleStreamingCompletion(
|
|
|
449
449
|
}),
|
|
450
450
|
);
|
|
451
451
|
}
|
|
452
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
452
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
453
453
|
ctx.state.aiApiStreamResult = {
|
|
454
454
|
succeeded: false,
|
|
455
455
|
id: completionId,
|
|
@@ -465,6 +465,24 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
465
465
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
466
466
|
}
|
|
467
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
|
+
|
|
468
486
|
/**
|
|
469
487
|
* Content block types every provider adapter in `@nocobase/plugin-ai` maps to a
|
|
470
488
|
* native equivalent.
|
|
@@ -475,7 +493,7 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
475
493
|
* the model answers as if the attachment was never sent. A 400 is far easier to
|
|
476
494
|
* debug than a confidently wrong completion.
|
|
477
495
|
*/
|
|
478
|
-
const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
|
|
496
|
+
const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url', 'file', 'file_url']);
|
|
479
497
|
|
|
480
498
|
/**
|
|
481
499
|
* Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
|
|
@@ -486,6 +504,14 @@ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
|
|
|
486
504
|
*/
|
|
487
505
|
const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
488
506
|
|
|
507
|
+
/**
|
|
508
|
+
* File blocks accept a wider range of MIME types than `image_url` blocks:
|
|
509
|
+
* documents such as `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
|
510
|
+
* or `image/svg+xml` are valid attachments. The grammar still requires a proper
|
|
511
|
+
* `type/subtype` and standard base64 payload.
|
|
512
|
+
*/
|
|
513
|
+
const FILE_BASE64_DATA_URL_PATTERN = /^data:([^;\s]+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
514
|
+
|
|
489
515
|
/**
|
|
490
516
|
* The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
|
|
491
517
|
* `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
|
|
@@ -591,8 +617,8 @@ function describeContentBlockProblem(block: unknown): string | undefined {
|
|
|
591
617
|
if (!type) return "each content block requires a 'type' field";
|
|
592
618
|
if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
|
|
593
619
|
return (
|
|
594
|
-
`content block type '${type}' is not supported — this gateway forwards 'text'
|
|
595
|
-
`Send documents as text, or inline them as
|
|
620
|
+
`content block type '${type}' is not supported — this gateway forwards 'text', 'image_url', ` +
|
|
621
|
+
`'file', and 'file_url' only. Send documents as text, or inline them as a 'file' / 'file_url' block`
|
|
596
622
|
);
|
|
597
623
|
}
|
|
598
624
|
|
|
@@ -600,9 +626,56 @@ function describeContentBlockProblem(block: unknown): string | undefined {
|
|
|
600
626
|
return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
|
|
601
627
|
}
|
|
602
628
|
|
|
629
|
+
if (type === 'file') {
|
|
630
|
+
return describeFileProblem(block.file);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (type === 'file_url') {
|
|
634
|
+
return describeFileUrlProblem(block.file_url);
|
|
635
|
+
}
|
|
636
|
+
|
|
603
637
|
return describeImageUrlProblem(block.image_url);
|
|
604
638
|
}
|
|
605
639
|
|
|
640
|
+
function describeFileProblem(file: unknown): string | undefined {
|
|
641
|
+
if (!isRecord(file)) return "a 'file' block requires an object 'file' field";
|
|
642
|
+
const fileData = typeof file.file_data === 'string' ? file.file_data : undefined;
|
|
643
|
+
if (!fileData) return "a 'file' block requires a string 'file.file_data' field";
|
|
644
|
+
if (!fileData.startsWith('data:')) {
|
|
645
|
+
return "a 'file' block's 'file_data' must be a base64 data URL starting with 'data:'";
|
|
646
|
+
}
|
|
647
|
+
const match = FILE_BASE64_DATA_URL_PATTERN.exec(fileData);
|
|
648
|
+
if (!match || !match[1].includes('/')) {
|
|
649
|
+
return (
|
|
650
|
+
`malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' ` +
|
|
651
|
+
`with a valid type/subtype and standard base64 (no whitespace or URL-safe characters)`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (!isDecodableBase64(match[2])) {
|
|
655
|
+
return (
|
|
656
|
+
`base64 payload is not decodable. Check the padding and length — ` +
|
|
657
|
+
`the data must be a multiple of 4 characters with at most two trailing '='`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function describeFileUrlProblem(fileUrl: unknown): string | undefined {
|
|
664
|
+
if (!isRecord(fileUrl)) return "a 'file_url' block requires an object 'file_url' field";
|
|
665
|
+
const url = typeof fileUrl.url === 'string' ? fileUrl.url : undefined;
|
|
666
|
+
if (!url) return "a 'file_url' block requires a string 'file_url.url' field";
|
|
667
|
+
let protocol: string;
|
|
668
|
+
try {
|
|
669
|
+
protocol = new URL(url).protocol;
|
|
670
|
+
} catch {
|
|
671
|
+
return `'${url}' is not a valid URL. Use an http(s) URL`;
|
|
672
|
+
}
|
|
673
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
674
|
+
return `URL protocol '${protocol}' is not supported. Use an http(s) URL`;
|
|
675
|
+
}
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
|
|
606
679
|
function describeImageUrlProblem(imageUrl: unknown): string | undefined {
|
|
607
680
|
const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
|
|
608
681
|
if (typeof url !== 'string' || url === '') {
|
|
@@ -681,7 +754,60 @@ export function normalizeMessageContent(content: unknown): MessageContent {
|
|
|
681
754
|
return JSON.stringify(content);
|
|
682
755
|
}
|
|
683
756
|
|
|
684
|
-
|
|
757
|
+
/**
|
|
758
|
+
* Run any `file` or `file_url` content blocks through the plugin's file
|
|
759
|
+
* processor service. Custom plugins can register processors to fetch URLs,
|
|
760
|
+
* extract text, OCR, etc. Other block types are left untouched.
|
|
761
|
+
*
|
|
762
|
+
* The service is invoked repeatedly if a processor returns another file-like
|
|
763
|
+
* block (e.g. a `file_url` becomes a `file` block, which may then be converted
|
|
764
|
+
* to images by the PDF processor).
|
|
765
|
+
*/
|
|
766
|
+
async function processMessageContentFileBlocks(
|
|
767
|
+
content: unknown,
|
|
768
|
+
ctx: Context,
|
|
769
|
+
plugin: PluginAiApiServer,
|
|
770
|
+
): Promise<unknown> {
|
|
771
|
+
if (!Array.isArray(content)) return content;
|
|
772
|
+
const processed: unknown[] = [];
|
|
773
|
+
for (const block of content) {
|
|
774
|
+
processed.push(...(await processFileBlockChain(block, ctx, plugin, 0)));
|
|
775
|
+
}
|
|
776
|
+
return processed;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const MAX_FILE_PROCESSOR_CHAIN_DEPTH = 3;
|
|
780
|
+
|
|
781
|
+
async function processFileBlockChain(
|
|
782
|
+
block: unknown,
|
|
783
|
+
ctx: Context,
|
|
784
|
+
plugin: PluginAiApiServer,
|
|
785
|
+
depth: number,
|
|
786
|
+
): Promise<unknown[]> {
|
|
787
|
+
if (!isRecord(block) || (block.type !== 'file' && block.type !== 'file_url')) {
|
|
788
|
+
return [block];
|
|
789
|
+
}
|
|
790
|
+
if (depth > MAX_FILE_PROCESSOR_CHAIN_DEPTH) {
|
|
791
|
+
return [block];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const result = await plugin.fileProcessorService.process(block as FileContentBlock, { ctx });
|
|
795
|
+
const results = Array.isArray(result) ? result : [result];
|
|
796
|
+
|
|
797
|
+
const next: unknown[] = [];
|
|
798
|
+
for (const item of results) {
|
|
799
|
+
if (isRecord(item) && (item.type === 'file' || item.type === 'file_url')) {
|
|
800
|
+
// The output is still a file-like block; run it through the chain again
|
|
801
|
+
// so that a `file_url` -> `file` -> images pipeline can complete.
|
|
802
|
+
next.push(...(await processFileBlockChain(item, ctx, plugin, depth + 1)));
|
|
803
|
+
} else {
|
|
804
|
+
next.push(item);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return next;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'prompt', 'tools', 'tool_choice', 'stream', 'n']);
|
|
685
811
|
|
|
686
812
|
export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
|
|
687
813
|
return Object.fromEntries(
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
isStreamingRequested,
|
|
24
24
|
writeResponse,
|
|
25
25
|
} from '../utils/streaming';
|
|
26
|
+
import { getProviderRequestParameters, applyProviderRequestParameters, extractFinishReason } from './chat-completions';
|
|
27
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
26
28
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
27
29
|
import type PluginAiApiServer from '../plugin';
|
|
28
30
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
@@ -102,7 +104,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
105
|
-
const config = await ctx
|
|
107
|
+
const config = await getAiApiConfig(ctx);
|
|
106
108
|
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
107
109
|
return;
|
|
108
110
|
}
|
|
@@ -133,20 +135,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
133
135
|
? body.prompt.join('\n')
|
|
134
136
|
: String(body.prompt);
|
|
135
137
|
|
|
136
|
-
//
|
|
137
|
-
const messages: OpenAIMessage[] = [];
|
|
138
|
-
if (config?.defaultAiEmployee) {
|
|
139
|
-
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
140
|
-
filter: { username: config.defaultAiEmployee },
|
|
141
|
-
});
|
|
142
|
-
if (employee) {
|
|
143
|
-
const systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
144
|
-
if (systemPrompt) {
|
|
145
|
-
messages.push({ role: 'system', content: systemPrompt });
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
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 }];
|
|
150
140
|
|
|
151
141
|
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
152
142
|
serviceName: service.name,
|
|
@@ -169,6 +159,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
169
159
|
|
|
170
160
|
const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
|
|
171
161
|
const chatModel = provider.createModel();
|
|
162
|
+
const providerRequestParameters = getProviderRequestParameters(body);
|
|
163
|
+
applyProviderRequestParameters(chatModel, providerRequestParameters);
|
|
172
164
|
markLlmProviderAttempted(ctx);
|
|
173
165
|
|
|
174
166
|
if (stream) {
|
|
@@ -179,9 +171,17 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
179
171
|
completionId,
|
|
180
172
|
body.model,
|
|
181
173
|
body.stream_options,
|
|
174
|
+
providerRequestParameters,
|
|
182
175
|
);
|
|
183
176
|
} else {
|
|
184
|
-
await handleNonStreamingTextCompletion(
|
|
177
|
+
await handleNonStreamingTextCompletion(
|
|
178
|
+
ctx,
|
|
179
|
+
chatModel,
|
|
180
|
+
langchainMessages,
|
|
181
|
+
completionId,
|
|
182
|
+
body.model,
|
|
183
|
+
providerRequestParameters,
|
|
184
|
+
);
|
|
185
185
|
}
|
|
186
186
|
} catch (err) {
|
|
187
187
|
ctx.log.error('AI API completions error:', err);
|
|
@@ -208,8 +208,9 @@ async function handleNonStreamingTextCompletion(
|
|
|
208
208
|
messages: [string, string][],
|
|
209
209
|
completionId: string,
|
|
210
210
|
modelName: string,
|
|
211
|
+
providerRequestParameters: Record<string, unknown>,
|
|
211
212
|
) {
|
|
212
|
-
const result = await chatModel.invoke(messages);
|
|
213
|
+
const result = await chatModel.invoke(messages, providerRequestParameters);
|
|
213
214
|
|
|
214
215
|
let text = '';
|
|
215
216
|
if (typeof result.content === 'string') {
|
|
@@ -219,10 +220,15 @@ async function handleNonStreamingTextCompletion(
|
|
|
219
220
|
text = textPart?.text || JSON.stringify(result.content);
|
|
220
221
|
}
|
|
221
222
|
|
|
222
|
-
const usage = setAiApiUsageResult(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
const usage = setAiApiUsageResult(
|
|
224
|
+
ctx,
|
|
225
|
+
result.usage_metadata,
|
|
226
|
+
{
|
|
227
|
+
gatewayResponseId: completionId,
|
|
228
|
+
providerRequestId: extractProviderRequestId(result),
|
|
229
|
+
},
|
|
230
|
+
result.response_metadata,
|
|
231
|
+
);
|
|
226
232
|
|
|
227
233
|
ctx.status = 200;
|
|
228
234
|
ctx.body = {
|
|
@@ -236,10 +242,17 @@ async function handleNonStreamingTextCompletion(
|
|
|
236
242
|
text,
|
|
237
243
|
index: 0,
|
|
238
244
|
logprobs: null,
|
|
239
|
-
finish_reason: 'stop',
|
|
245
|
+
finish_reason: extractFinishReason(result) ?? 'stop',
|
|
240
246
|
},
|
|
241
247
|
],
|
|
242
|
-
usage: usage
|
|
248
|
+
usage: usage
|
|
249
|
+
? {
|
|
250
|
+
prompt_tokens: usage.prompt_tokens,
|
|
251
|
+
completion_tokens: usage.completion_tokens,
|
|
252
|
+
total_tokens: usage.total_tokens,
|
|
253
|
+
prompt_tokens_details: { cached_tokens: usage.prompt_cache_tokens ?? null },
|
|
254
|
+
}
|
|
255
|
+
: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: null } },
|
|
243
256
|
};
|
|
244
257
|
}
|
|
245
258
|
|
|
@@ -252,6 +265,7 @@ async function handleStreamingTextCompletion(
|
|
|
252
265
|
completionId: string,
|
|
253
266
|
modelName: string,
|
|
254
267
|
streamOptions: Record<string, unknown> | undefined,
|
|
268
|
+
providerRequestParameters: Record<string, unknown> | undefined,
|
|
255
269
|
) {
|
|
256
270
|
ctx.set({
|
|
257
271
|
'Content-Type': 'text/event-stream',
|
|
@@ -263,9 +277,12 @@ async function handleStreamingTextCompletion(
|
|
|
263
277
|
|
|
264
278
|
const requestAbort = createRequestAbortController(ctx);
|
|
265
279
|
let usage: Usage | undefined;
|
|
280
|
+
let usageResponseMetadata: unknown;
|
|
266
281
|
let providerRequestId: string | undefined;
|
|
282
|
+
let providerFinishReason: string | undefined;
|
|
267
283
|
try {
|
|
268
284
|
const stream = await chatModel.stream(messages, {
|
|
285
|
+
...providerRequestParameters,
|
|
269
286
|
stream_options: { ...streamOptions, include_usage: true },
|
|
270
287
|
signal: requestAbort.signal,
|
|
271
288
|
});
|
|
@@ -303,8 +320,14 @@ async function handleStreamingTextCompletion(
|
|
|
303
320
|
);
|
|
304
321
|
}
|
|
305
322
|
if (chunk.usage_metadata) {
|
|
306
|
-
|
|
323
|
+
const normalized = normalizeUsage(chunk.usage_metadata);
|
|
324
|
+
if (normalized) {
|
|
325
|
+
usage = normalized;
|
|
326
|
+
usageResponseMetadata = chunk.response_metadata;
|
|
327
|
+
}
|
|
307
328
|
}
|
|
329
|
+
const chunkFinishReason = extractFinishReason(chunk);
|
|
330
|
+
if (chunkFinishReason) providerFinishReason = chunkFinishReason;
|
|
308
331
|
providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
|
|
309
332
|
}
|
|
310
333
|
|
|
@@ -322,7 +345,7 @@ async function handleStreamingTextCompletion(
|
|
|
322
345
|
text: '',
|
|
323
346
|
index: 0,
|
|
324
347
|
logprobs: null,
|
|
325
|
-
finish_reason: 'stop',
|
|
348
|
+
finish_reason: providerFinishReason ?? 'stop',
|
|
326
349
|
},
|
|
327
350
|
],
|
|
328
351
|
usage: null,
|
|
@@ -344,7 +367,7 @@ async function handleStreamingTextCompletion(
|
|
|
344
367
|
}
|
|
345
368
|
|
|
346
369
|
await writeResponse(ctx, formatSSEDone());
|
|
347
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
370
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
348
371
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
349
372
|
} catch (err) {
|
|
350
373
|
const cancelled = isClientDisconnected(ctx, err);
|
|
@@ -360,7 +383,7 @@ async function handleStreamingTextCompletion(
|
|
|
360
383
|
}),
|
|
361
384
|
);
|
|
362
385
|
}
|
|
363
|
-
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
386
|
+
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
|
|
364
387
|
ctx.state.aiApiStreamResult = {
|
|
365
388
|
succeeded: false,
|
|
366
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') {
|