plugin-ai-api 1.0.20 → 1.0.23
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/123.e6fe04c856ce6417.js +10 -0
- package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
- package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
- package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
- package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +39 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +39 -9
- package/dist/locale/vi-VN.json +31 -1
- package/dist/locale/zh-CN.json +31 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/plugin.js +45 -1
- package/dist/server/resource/ai-api-config.js +17 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +67 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +174 -20
- package/dist/server/routes/completions.js +41 -21
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +102 -20
- package/dist/server/routes/router.js +94 -22
- package/dist/server/usage.js +2 -0
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/openai-format.js +17 -3
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/server/validation.js +18 -0
- package/dist/swagger.js +36 -4
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +28 -8
- package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -1
- package/src/client-v2/pages/GeneralPage.tsx +13 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/plugin.tsx +50 -1
- package/src/constants.ts +28 -0
- package/src/locale/en-US.json +39 -9
- package/src/locale/vi-VN.json +31 -1
- package/src/locale/zh-CN.json +31 -1
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/models.test.ts +116 -0
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-route.test.ts +213 -0
- package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
- package/src/server/__tests__/user-permissions.test.ts +284 -0
- package/src/server/collections/ai-api-config.ts +6 -0
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-user-permissions.ts +46 -0
- package/src/server/plugin.ts +65 -4
- package/src/server/resource/ai-api-config.ts +23 -0
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +84 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +294 -20
- package/src/server/routes/completions.ts +54 -20
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +318 -195
- package/src/server/routes/router.ts +136 -26
- package/src/server/usage.ts +2 -0
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/openai-format.ts +26 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/server/validation.ts +27 -0
- package/src/swagger.ts +47 -4
- package/dist/client/302.25edd5d75460acbf.js +0 -10
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../utils/openai-format';
|
|
19
19
|
import { resolveModelString } from '../utils/resolve-service';
|
|
20
20
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
21
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
21
22
|
import { isStreamingRequested } from '../utils/streaming';
|
|
22
23
|
import {
|
|
23
24
|
AgentRuntimeContext,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
} from '../utils/ai-employee-runtime';
|
|
28
29
|
import { setAiApiUsageUnavailable } from '../usage';
|
|
29
30
|
import type PluginAiApiServer from '../plugin';
|
|
31
|
+
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* POST /api/ai-llm/v1/chat/completions (agent mode)
|
|
@@ -120,6 +122,12 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
120
122
|
return;
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
// ─── Check whitelist (global config ∩ per-user grant) ──────────────────────
|
|
126
|
+
const globalEnabledServices = config ? config.get('enabledLlmServices') || config.enabledLlmServices : [];
|
|
127
|
+
if (!(await enforceModelAccess(ctx, globalEnabledServices, service, modelId))) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
123
131
|
const employeeUsername = defaultAiEmployee;
|
|
124
132
|
|
|
125
133
|
// ─── Check role is allowed to use this employee ────────────────────────────
|
|
@@ -281,85 +289,97 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
281
289
|
// Don't actually end — we control this
|
|
282
290
|
};
|
|
283
291
|
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
292
|
+
// Translate one buffered NocoBase SSE frame → OpenAI SSE.
|
|
293
|
+
const processSseFrame = (frame: string): void => {
|
|
294
|
+
for (const line of frame.split('\n')) {
|
|
295
|
+
const trimmed = line.trim();
|
|
296
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
297
|
+
|
|
298
|
+
const jsonStr = trimmed.substring(6);
|
|
299
|
+
if (!jsonStr) continue;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const event = JSON.parse(jsonStr);
|
|
303
|
+
|
|
304
|
+
if (event.type === 'content' && event.body) {
|
|
305
|
+
markAiApiFirstProviderOutput(ctx);
|
|
306
|
+
// Content chunk — forward as OpenAI delta
|
|
307
|
+
originalWrite(
|
|
308
|
+
formatSSE(
|
|
309
|
+
toOpenAIStreamChunk({
|
|
310
|
+
id: completionId,
|
|
311
|
+
model: body.model,
|
|
312
|
+
delta: { content: String(event.body) },
|
|
313
|
+
}),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
} else if (event.type === 'tool_call_chunks' && Array.isArray(event.body)) {
|
|
317
|
+
const chunks = toOpenAIToolCallChunks(event.body);
|
|
318
|
+
if (chunks.length) {
|
|
319
|
+
markAiApiFirstProviderOutput(ctx);
|
|
320
|
+
sawToolCalls = true;
|
|
303
321
|
originalWrite(
|
|
304
322
|
formatSSE(
|
|
305
323
|
toOpenAIStreamChunk({
|
|
306
324
|
id: completionId,
|
|
307
325
|
model: body.model,
|
|
308
|
-
delta: {
|
|
326
|
+
delta: { tool_calls: chunks },
|
|
309
327
|
}),
|
|
310
328
|
),
|
|
311
329
|
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
toOpenAIStreamChunk({
|
|
319
|
-
id: completionId,
|
|
320
|
-
model: body.model,
|
|
321
|
-
delta: { tool_calls: chunks },
|
|
322
|
-
}),
|
|
323
|
-
),
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
} else if (!sawToolCalls && event.type === 'tool_calls' && Array.isArray(event.body?.toolCalls)) {
|
|
327
|
-
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
328
|
-
if (chunks.length) {
|
|
329
|
-
sawToolCalls = true;
|
|
330
|
-
originalWrite(
|
|
331
|
-
formatSSE(
|
|
332
|
-
toOpenAIStreamChunk({
|
|
333
|
-
id: completionId,
|
|
334
|
-
model: body.model,
|
|
335
|
-
delta: { tool_calls: chunks },
|
|
336
|
-
}),
|
|
337
|
-
),
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
} else if (event.type === 'error' && event.body) {
|
|
341
|
-
// Error from the agent — surface as SSE error object
|
|
330
|
+
}
|
|
331
|
+
} else if (!sawToolCalls && event.type === 'tool_calls' && Array.isArray(event.body?.toolCalls)) {
|
|
332
|
+
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
333
|
+
if (chunks.length) {
|
|
334
|
+
markAiApiFirstProviderOutput(ctx);
|
|
335
|
+
sawToolCalls = true;
|
|
342
336
|
originalWrite(
|
|
343
|
-
formatSSE(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
},
|
|
349
|
-
|
|
337
|
+
formatSSE(
|
|
338
|
+
toOpenAIStreamChunk({
|
|
339
|
+
id: completionId,
|
|
340
|
+
model: body.model,
|
|
341
|
+
delta: { tool_calls: chunks },
|
|
342
|
+
}),
|
|
343
|
+
),
|
|
350
344
|
);
|
|
351
345
|
}
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
346
|
+
} else if (event.type === 'error' && event.body) {
|
|
347
|
+
// Error from the agent — surface as SSE error object
|
|
348
|
+
originalWrite(
|
|
349
|
+
formatSSE({
|
|
350
|
+
error: {
|
|
351
|
+
message: String(event.body),
|
|
352
|
+
type: 'server_error',
|
|
353
|
+
code: 'agent_error',
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
357
|
}
|
|
358
|
+
// stream_start, stream_end, tool_call_status, web_search,
|
|
359
|
+
// reasoning and new_message are NocoBase-only events and are ignored.
|
|
360
|
+
// These are NocoBase-internal events not part of the OpenAI protocol.
|
|
361
|
+
} catch {
|
|
362
|
+
// Non-JSON SSE line — ignore
|
|
358
363
|
}
|
|
359
364
|
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Intercept write() — buffer input and translate complete frames.
|
|
368
|
+
(ctx.res as any).write = (data: Buffer | string): boolean => {
|
|
369
|
+
pendingSse += typeof data === 'string' ? data : data.toString('utf8');
|
|
370
|
+
const frames = pendingSse.split('\n\n');
|
|
371
|
+
pendingSse = frames.pop() || '';
|
|
372
|
+
for (const frame of frames) processSseFrame(frame);
|
|
360
373
|
return true;
|
|
361
374
|
};
|
|
362
375
|
|
|
376
|
+
// Flush any trailing frame that was not terminated by '\n\n'.
|
|
377
|
+
const flushPendingSse = (): void => {
|
|
378
|
+
if (!pendingSse.trim()) return;
|
|
379
|
+
processSseFrame(pendingSse);
|
|
380
|
+
pendingSse = '';
|
|
381
|
+
};
|
|
382
|
+
|
|
363
383
|
try {
|
|
364
384
|
const aiEmployee = new AIEmployee(
|
|
365
385
|
createAIEmployeeOptions(ctx, employeeRecord, sessionId, {
|
|
@@ -385,6 +405,8 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
385
405
|
ctx.res.off('close', abortAgent);
|
|
386
406
|
|
|
387
407
|
if (streamSucceeded && !ctx.res.destroyed) {
|
|
408
|
+
// Emit any final frame the agent left unterminated before closing.
|
|
409
|
+
flushPendingSse();
|
|
388
410
|
originalWrite(
|
|
389
411
|
formatSSE(
|
|
390
412
|
toOpenAIStreamChunk({
|
|
@@ -47,7 +47,20 @@ export async function authenticateBearer(ctx: Context): Promise<boolean> {
|
|
|
47
47
|
const rolesRepository = ctx.db.getRepository('users.roles', ctx.state.currentUser.id);
|
|
48
48
|
const roles = await rolesRepository.find({ fields: ['name'] });
|
|
49
49
|
const roleNames = roles.map((role: { name: string }) => role.name);
|
|
50
|
-
|
|
50
|
+
// An explicit X-Role that the user does not hold must be rejected, not
|
|
51
|
+
// silently downgraded to the first role — otherwise a caller could probe
|
|
52
|
+
// for access under a role they were never granted.
|
|
53
|
+
if (requestedRole && !roleNames.includes(requestedRole)) {
|
|
54
|
+
ctx.status = 403;
|
|
55
|
+
ctx.body = toOpenAIError(
|
|
56
|
+
403,
|
|
57
|
+
`Requested role '${requestedRole}' is not assigned to this user`,
|
|
58
|
+
'permission_denied',
|
|
59
|
+
'role_not_permitted',
|
|
60
|
+
);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
ctx.state.currentRole = requestedRole || roleNames[0];
|
|
51
64
|
ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames;
|
|
52
65
|
}
|
|
53
66
|
return true;
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
generateCompletionId,
|
|
13
13
|
toOpenAIResponse,
|
|
14
14
|
toOpenAIStreamChunk,
|
|
15
|
+
toOpenAIUsageChunk,
|
|
15
16
|
toOpenAIError,
|
|
16
17
|
formatSSE,
|
|
17
18
|
formatSSEDone,
|
|
@@ -19,11 +20,18 @@ import {
|
|
|
19
20
|
OpenAIToolCallChunk,
|
|
20
21
|
} from '../utils/openai-format';
|
|
21
22
|
import { resolveModelString } from '../utils/resolve-service';
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createRequestAbortController,
|
|
25
|
+
isClientDisconnected,
|
|
26
|
+
isStreamingRequested,
|
|
27
|
+
writeResponse,
|
|
28
|
+
} from '../utils/streaming';
|
|
23
29
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
30
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
24
31
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
25
32
|
import type PluginAiApiServer from '../plugin';
|
|
26
33
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
34
|
+
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* POST /api/ai-llm/v1/chat/completions
|
|
@@ -47,6 +55,30 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
47
55
|
return;
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
const messageProblem = findMessageProblem(body.messages);
|
|
59
|
+
if (messageProblem) {
|
|
60
|
+
ctx.status = 400;
|
|
61
|
+
ctx.body = toOpenAIError(
|
|
62
|
+
400,
|
|
63
|
+
`Invalid messages[${messageProblem.index}]: ${messageProblem.reason}.`,
|
|
64
|
+
'invalid_request_error',
|
|
65
|
+
'invalid_message',
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const blockProblem = findContentBlockProblem(body.messages);
|
|
71
|
+
if (blockProblem) {
|
|
72
|
+
ctx.status = 400;
|
|
73
|
+
ctx.body = toOpenAIError(
|
|
74
|
+
400,
|
|
75
|
+
`Invalid content block in messages[${blockProblem.index}]: ${blockProblem.reason}.`,
|
|
76
|
+
'invalid_request_error',
|
|
77
|
+
'invalid_content_block',
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
// ─── Reject unsupported n parameter ───
|
|
51
83
|
if (body.n !== undefined && body.n !== null && body.n !== 1) {
|
|
52
84
|
ctx.status = 400;
|
|
@@ -96,22 +128,10 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
96
128
|
return;
|
|
97
129
|
}
|
|
98
130
|
|
|
99
|
-
// ─── Check whitelist ───
|
|
131
|
+
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
100
132
|
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
101
|
-
if (config?.enabledLlmServices
|
|
102
|
-
|
|
103
|
-
const serviceTitle = service.title;
|
|
104
|
-
const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
|
|
105
|
-
if (!isAllowed) {
|
|
106
|
-
ctx.status = 403;
|
|
107
|
-
ctx.body = toOpenAIError(
|
|
108
|
-
403,
|
|
109
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
110
|
-
'invalid_request_error',
|
|
111
|
-
'model_not_available',
|
|
112
|
-
);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
133
|
+
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
134
|
+
return;
|
|
115
135
|
}
|
|
116
136
|
|
|
117
137
|
// ─── Create LLM provider instance ───
|
|
@@ -125,6 +145,13 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
125
145
|
await prepareLlmBilling(ctx, resolved);
|
|
126
146
|
|
|
127
147
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
148
|
+
if (stream) {
|
|
149
|
+
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
150
|
+
providerRequestParameters.stream_options = {
|
|
151
|
+
...streamOptions,
|
|
152
|
+
include_usage: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
128
155
|
const modelOptions: Record<string, unknown> = {
|
|
129
156
|
model: modelId,
|
|
130
157
|
llmService: service.name,
|
|
@@ -179,9 +206,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
179
206
|
// ─── Build message tuples for LangChain model ───
|
|
180
207
|
// LangChain chat models accept [role, content] tuples or BaseMessage objects.
|
|
181
208
|
// We use tuples to avoid importing @langchain/core directly.
|
|
209
|
+
//
|
|
210
|
+
// `content` may be a string OR an array of content blocks (OpenAI vision
|
|
211
|
+
// format: [{type:'text'}, {type:'image_url', image_url:{url:'data:...'}}]).
|
|
212
|
+
// Arrays must be forwarded structurally — stringifying them would turn an
|
|
213
|
+
// image into literal JSON text and the model would never see the picture.
|
|
182
214
|
const langchainMessages = messages.map((msg: any) => {
|
|
183
215
|
const role = msg.role === 'assistant' ? 'ai' : msg.role;
|
|
184
|
-
const content =
|
|
216
|
+
const content = normalizeMessageContent(msg.content);
|
|
185
217
|
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
186
218
|
return {
|
|
187
219
|
role,
|
|
@@ -193,7 +225,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
193
225
|
if (msg.role === 'tool') {
|
|
194
226
|
return { role: 'tool', content, tool_call_id: msg.tool_call_id, name: msg.name };
|
|
195
227
|
}
|
|
196
|
-
return [role, content] as [string,
|
|
228
|
+
return [role, content] as [string, MessageContent];
|
|
197
229
|
});
|
|
198
230
|
|
|
199
231
|
const completionId = generateCompletionId();
|
|
@@ -326,6 +358,7 @@ async function handleStreamingCompletion(
|
|
|
326
358
|
}
|
|
327
359
|
|
|
328
360
|
if (content) {
|
|
361
|
+
markAiApiFirstProviderOutput(ctx);
|
|
329
362
|
await writeResponse(
|
|
330
363
|
ctx,
|
|
331
364
|
formatSSE(
|
|
@@ -340,10 +373,17 @@ async function handleStreamingCompletion(
|
|
|
340
373
|
|
|
341
374
|
const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
|
|
342
375
|
if (toolCallChunks.length) {
|
|
376
|
+
markAiApiFirstProviderOutput(ctx);
|
|
343
377
|
finishReason = 'tool_calls';
|
|
344
378
|
await writeResponse(
|
|
345
379
|
ctx,
|
|
346
|
-
formatSSE(
|
|
380
|
+
formatSSE(
|
|
381
|
+
toOpenAIStreamChunk({
|
|
382
|
+
id: completionId,
|
|
383
|
+
model: modelName,
|
|
384
|
+
delta: { tool_calls: toolCallChunks },
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
347
387
|
);
|
|
348
388
|
}
|
|
349
389
|
if (chunk.usage_metadata) {
|
|
@@ -365,11 +405,25 @@ async function handleStreamingCompletion(
|
|
|
365
405
|
),
|
|
366
406
|
);
|
|
367
407
|
|
|
408
|
+
if (usage) {
|
|
409
|
+
await writeResponse(
|
|
410
|
+
ctx,
|
|
411
|
+
formatSSE(
|
|
412
|
+
toOpenAIUsageChunk({
|
|
413
|
+
id: completionId,
|
|
414
|
+
model: modelName,
|
|
415
|
+
usage,
|
|
416
|
+
}),
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
368
421
|
// Send [DONE]
|
|
369
422
|
await writeResponse(ctx, formatSSEDone());
|
|
370
423
|
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
371
424
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
372
425
|
} catch (err) {
|
|
426
|
+
const cancelled = isClientDisconnected(ctx, err);
|
|
373
427
|
ctx.log.error('AI API streaming error:', err);
|
|
374
428
|
// Send error as SSE event before closing
|
|
375
429
|
if (!ctx.res.destroyed && !ctx.res.writableEnded) {
|
|
@@ -384,7 +438,11 @@ async function handleStreamingCompletion(
|
|
|
384
438
|
);
|
|
385
439
|
}
|
|
386
440
|
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
387
|
-
ctx.state.aiApiStreamResult = {
|
|
441
|
+
ctx.state.aiApiStreamResult = {
|
|
442
|
+
succeeded: false,
|
|
443
|
+
id: completionId,
|
|
444
|
+
errorCode: cancelled ? 'client_disconnected' : 'stream_error',
|
|
445
|
+
};
|
|
388
446
|
} finally {
|
|
389
447
|
requestAbort.dispose();
|
|
390
448
|
if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
|
|
@@ -395,6 +453,222 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
395
453
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
396
454
|
}
|
|
397
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Content block types every provider adapter in `@nocobase/plugin-ai` maps to a
|
|
458
|
+
* native equivalent.
|
|
459
|
+
*
|
|
460
|
+
* Anything else is rejected rather than forwarded: the LangChain block
|
|
461
|
+
* converters are if/else-if chains with no fallback branch, so an unrecognized
|
|
462
|
+
* block (OpenAI's `{type:'file'}` on Anthropic, for example) yields nothing and
|
|
463
|
+
* the model answers as if the attachment was never sent. A 400 is far easier to
|
|
464
|
+
* debug than a confidently wrong completion.
|
|
465
|
+
*/
|
|
466
|
+
const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
|
|
470
|
+
* accepts (`\w+/\w+`, standard base64). A looser pattern here would admit URLs
|
|
471
|
+
* the provider adapter then fails to parse — it falls through to `new URL()`,
|
|
472
|
+
* sees the `data:` protocol and throws, which reaches the client as a 500.
|
|
473
|
+
* Compound subtypes such as `image/svg+xml` are rejected for that reason.
|
|
474
|
+
*/
|
|
475
|
+
const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
|
|
479
|
+
* `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
|
|
480
|
+
* calls `atob` on the payload, which throws a DOMException for each of them, and
|
|
481
|
+
* that escapes as an HTTP 500. Re-encoding is the cheapest exact check — the
|
|
482
|
+
* canonical form of a valid payload is the payload itself.
|
|
483
|
+
*/
|
|
484
|
+
function isDecodableBase64(payload: string): boolean {
|
|
485
|
+
try {
|
|
486
|
+
return Buffer.from(payload, 'base64').toString('base64') === payload;
|
|
487
|
+
} catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export interface ContentBlockProblem {
|
|
493
|
+
index: number;
|
|
494
|
+
reason: string;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Roles `_constructMessageFromParams` can turn into a message. Anything else
|
|
499
|
+
* reaches its final `else` and throws MESSAGE_COERCION_FAILURE.
|
|
500
|
+
*
|
|
501
|
+
* `function` is deliberately absent: OpenAI deprecated it, and LangChain has no
|
|
502
|
+
* branch for it despite mapping the class name internally.
|
|
503
|
+
*/
|
|
504
|
+
const SUPPORTED_MESSAGE_ROLES = new Set(['system', 'developer', 'user', 'human', 'assistant', 'ai', 'tool']);
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Validate the shape of each message before any provider work happens.
|
|
508
|
+
*
|
|
509
|
+
* Requiring only "non-empty array" lets `messages: [null]` through to
|
|
510
|
+
* `messages.some((m) => m.role === 'system')`, which throws a TypeError and is
|
|
511
|
+
* reported as a 500. An unsupported role travels further still and dies inside
|
|
512
|
+
* LangChain with MESSAGE_COERCION_FAILURE. Both are caller errors, so both
|
|
513
|
+
* should be a 400 that names the offending index.
|
|
514
|
+
*/
|
|
515
|
+
export function findMessageProblem(messages: unknown[]): ContentBlockProblem | undefined {
|
|
516
|
+
for (const [index, message] of messages.entries()) {
|
|
517
|
+
if (!isRecord(message)) return { index, reason: 'each message must be an object' };
|
|
518
|
+
|
|
519
|
+
const role = typeof message.role === 'string' ? message.role : undefined;
|
|
520
|
+
if (!role) return { index, reason: "each message requires a string 'role' field" };
|
|
521
|
+
if (!SUPPORTED_MESSAGE_ROLES.has(role)) {
|
|
522
|
+
return {
|
|
523
|
+
index,
|
|
524
|
+
reason: `role '${role}' is not supported — use one of ` + `${[...SUPPORTED_MESSAGE_ROLES].join(', ')}`,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (role === 'tool' && typeof message.tool_call_id !== 'string') {
|
|
529
|
+
return { index, reason: "a 'tool' message requires a string 'tool_call_id' field" };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const { content } = message;
|
|
533
|
+
const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
534
|
+
if (content === undefined || content === null) {
|
|
535
|
+
// An assistant turn that only calls tools legitimately carries no content.
|
|
536
|
+
if ((role === 'assistant' || role === 'ai') && hasToolCalls) continue;
|
|
537
|
+
return { index, reason: "each message requires a 'content' field" };
|
|
538
|
+
}
|
|
539
|
+
if (typeof content !== 'string' && !Array.isArray(content)) {
|
|
540
|
+
return { index, reason: "'content' must be a string or an array of content blocks" };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Validate the multimodal content blocks of a chat request.
|
|
548
|
+
*
|
|
549
|
+
* Checking `type` alone is not enough. The provider adapters either drop or
|
|
550
|
+
* throw on malformed blocks, and both outcomes surface badly:
|
|
551
|
+
*
|
|
552
|
+
* - A block whose payload fails every branch of the converter yields nothing,
|
|
553
|
+
* so the model answers as if the attachment was never sent.
|
|
554
|
+
* - `_formatImage` throws on a malformed or non-http(s) URL, and that escapes as
|
|
555
|
+
* a generic HTTP 500 instead of telling the caller what was wrong.
|
|
556
|
+
* - `parseBase64DataUrl` matches any `data:<type>/<subtype>;base64,` URL without
|
|
557
|
+
* checking for `image/*`, so a PDF becomes an `image` block with
|
|
558
|
+
* `media_type: application/pdf` that the model cannot read.
|
|
559
|
+
*
|
|
560
|
+
* Validating up front turns all of those into an actionable 400.
|
|
561
|
+
*/
|
|
562
|
+
export function findContentBlockProblem(messages: unknown[]): ContentBlockProblem | undefined {
|
|
563
|
+
for (const [index, message] of messages.entries()) {
|
|
564
|
+
const content = isRecord(message) ? message.content : undefined;
|
|
565
|
+
if (!Array.isArray(content)) continue;
|
|
566
|
+
for (const block of content) {
|
|
567
|
+
if (typeof block === 'string') continue;
|
|
568
|
+
const reason = describeContentBlockProblem(block);
|
|
569
|
+
if (reason) return { index, reason };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function describeContentBlockProblem(block: unknown): string | undefined {
|
|
576
|
+
if (!isRecord(block)) return 'each content block must be an object';
|
|
577
|
+
|
|
578
|
+
const type = typeof block.type === 'string' ? block.type : undefined;
|
|
579
|
+
if (!type) return "each content block requires a 'type' field";
|
|
580
|
+
if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
|
|
581
|
+
return (
|
|
582
|
+
`content block type '${type}' is not supported — this gateway forwards 'text' and 'image_url' only. ` +
|
|
583
|
+
`Send documents as text, or inline them as an 'image_url' data URL if the model reads images`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (type === 'text') {
|
|
588
|
+
return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return describeImageUrlProblem(block.image_url);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function describeImageUrlProblem(imageUrl: unknown): string | undefined {
|
|
595
|
+
const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
|
|
596
|
+
if (typeof url !== 'string' || url === '') {
|
|
597
|
+
return "an 'image_url' block requires a non-empty 'image_url.url' string";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (url.startsWith('data:')) {
|
|
601
|
+
const match = BASE64_DATA_URL_PATTERN.exec(url);
|
|
602
|
+
if (!match) {
|
|
603
|
+
return (
|
|
604
|
+
`malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' ` +
|
|
605
|
+
`with standard base64 (no whitespace or URL-safe characters)`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const mimeType = match[1].toLowerCase();
|
|
609
|
+
if (!mimeType.startsWith('image/')) {
|
|
610
|
+
return (
|
|
611
|
+
`data URL MIME type '${mimeType}' is not an image. Only 'image/*' data URLs are forwarded, ` +
|
|
612
|
+
`because providers reject or ignore other types on an 'image_url' block`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (!isDecodableBase64(match[2])) {
|
|
616
|
+
return (
|
|
617
|
+
`base64 payload is not decodable. Check the padding and length — ` +
|
|
618
|
+
`the data must be a multiple of 4 characters with at most two trailing '='`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
let protocol: string;
|
|
625
|
+
try {
|
|
626
|
+
protocol = new URL(url).protocol;
|
|
627
|
+
} catch {
|
|
628
|
+
return `'${url}' is not a valid URL. Use an http(s) URL or a base64 data URL`;
|
|
629
|
+
}
|
|
630
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
631
|
+
return `URL protocol '${protocol}' is not supported. Use an http(s) URL or a base64 data URL`;
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* A LangChain message content value: plain text, or an array of content blocks
|
|
638
|
+
* (`{type:'text'}`, `{type:'image_url'}`, ...) for multimodal requests.
|
|
639
|
+
*/
|
|
640
|
+
type MessageContent = string | Record<string, unknown>[];
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Normalize an OpenAI `message.content` into something LangChain accepts.
|
|
644
|
+
*
|
|
645
|
+
* Content blocks are passed through unchanged so vision requests reach the
|
|
646
|
+
* provider intact — `@langchain/core` coerces `image_url` blocks into the
|
|
647
|
+
* provider's native format. Only genuinely unusable shapes (numbers, objects)
|
|
648
|
+
* are stringified as a last resort.
|
|
649
|
+
*
|
|
650
|
+
* The one rewrite is `image_url: '<url>'` → `image_url: { url: '<url>' }`.
|
|
651
|
+
* `isOpenAIDataBlock` gates on `_isObject(block.image_url)`, so the string form
|
|
652
|
+
* is never recognised as a data block: core forwards it untouched and only
|
|
653
|
+
* Anthropic's adapter happens to accept it. Widening it here keeps the lenient
|
|
654
|
+
* request working on every provider instead of just one.
|
|
655
|
+
*/
|
|
656
|
+
export function normalizeMessageContent(content: unknown): MessageContent {
|
|
657
|
+
if (typeof content === 'string') return content;
|
|
658
|
+
if (Array.isArray(content)) {
|
|
659
|
+
return content.map((block) => {
|
|
660
|
+
if (typeof block === 'string') return { type: 'text', text: block };
|
|
661
|
+
const record = block as Record<string, unknown>;
|
|
662
|
+
if (record?.type === 'image_url' && typeof record.image_url === 'string') {
|
|
663
|
+
return { ...record, image_url: { url: record.image_url } };
|
|
664
|
+
}
|
|
665
|
+
return record;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
if (content === null || content === undefined) return '';
|
|
669
|
+
return JSON.stringify(content);
|
|
670
|
+
}
|
|
671
|
+
|
|
398
672
|
const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'tools', 'tool_choice', 'stream', 'n']);
|
|
399
673
|
|
|
400
674
|
export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
|