plugin-ai-api 1.0.20 → 1.0.21

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.
Files changed (72) hide show
  1. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  2. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  3. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  4. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  5. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  6. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  9. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  10. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  11. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +36 -0
  16. package/dist/externalVersion.js +9 -10
  17. package/dist/locale/en-US.json +28 -9
  18. package/dist/locale/vi-VN.json +20 -1
  19. package/dist/locale/zh-CN.json +20 -1
  20. package/dist/server/collections/ai-api-config.js +6 -0
  21. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  22. package/dist/server/plugin.js +13 -1
  23. package/dist/server/resource/ai-api-config.js +17 -0
  24. package/dist/server/routes/agent-completions.js +62 -51
  25. package/dist/server/routes/auth.js +11 -1
  26. package/dist/server/routes/chat-completions.js +145 -4
  27. package/dist/server/routes/completions.js +8 -1
  28. package/dist/server/routes/models.js +78 -20
  29. package/dist/server/routes/router.js +94 -22
  30. package/dist/server/usage.js +2 -0
  31. package/dist/server/utils/app-observability.js +110 -0
  32. package/dist/server/utils/streaming.js +15 -1
  33. package/dist/server/validation.js +18 -0
  34. package/dist/swagger.js +32 -1
  35. package/package.json +1 -1
  36. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  37. package/src/client/locale.ts +11 -21
  38. package/src/client/plugin.tsx +17 -8
  39. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  40. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  41. package/src/client-v2/locale.ts +21 -1
  42. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  44. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  45. package/src/client-v2/plugin.tsx +41 -1
  46. package/src/constants.ts +21 -0
  47. package/src/locale/en-US.json +28 -9
  48. package/src/locale/vi-VN.json +20 -1
  49. package/src/locale/zh-CN.json +20 -1
  50. package/src/server/__tests__/app-observability.test.ts +98 -0
  51. package/src/server/__tests__/models.test.ts +74 -0
  52. package/src/server/__tests__/request-body.test.ts +310 -0
  53. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  54. package/src/server/collections/ai-api-config.ts +6 -0
  55. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  56. package/src/server/plugin.ts +24 -4
  57. package/src/server/resource/ai-api-config.ts +23 -0
  58. package/src/server/routes/agent-completions.ts +77 -62
  59. package/src/server/routes/auth.ts +14 -1
  60. package/src/server/routes/chat-completions.ts +262 -4
  61. package/src/server/routes/completions.ts +14 -2
  62. package/src/server/routes/models.ts +290 -195
  63. package/src/server/routes/router.ts +136 -26
  64. package/src/server/usage.ts +2 -0
  65. package/src/server/utils/app-observability.ts +105 -0
  66. package/src/server/utils/streaming.ts +13 -1
  67. package/src/server/validation.ts +27 -0
  68. package/src/swagger.ts +38 -1
  69. package/dist/client/302.25edd5d75460acbf.js +0 -10
  70. package/dist/client/778.5c452944cb747975.js +0 -10
  71. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  72. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -8,12 +8,13 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from '@nocobase/server';
11
- import { createAiLlmRouter } from './routes/router';
11
+ import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
12
12
  import aiApiConfigResource from './resource/ai-api-config';
13
13
  import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
14
14
  import { RateLimiter } from './utils/rate-limiter';
15
15
  import { invalidateRolePermissionCache } from './middleware/role-permission';
16
- import { validateModelPrice, validateQuotaPolicy } from './validation';
16
+ import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
17
+ import { AI_API_ACL_SNIPPET } from '../constants';
17
18
 
18
19
  // Ensure dayjs timezone + utc plugins are loaded.
19
20
  // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
@@ -43,13 +44,31 @@ export class PluginAiApiServer extends Plugin {
43
44
  this.app.db.on('aiApiModelPrices.beforeSave', async (model) => {
44
45
  await validateModelPrice(this.db, model);
45
46
  });
47
+ this.app.db.on('aiApiModelMetadata.beforeSave', (model) => {
48
+ validateModelMetadata(model);
49
+ });
46
50
  this.app.db.on('aiApiUserQuotaPolicies.beforeSave', (model) => {
47
51
  validateQuotaPolicy(model);
48
52
  });
49
53
  }
50
54
 
51
55
  async load() {
52
- // 1. Register raw Koa middleware for OpenAI-compatible endpoints
56
+ // 1. Claim body parsing for our own routes before the core bodyParser runs.
57
+ // Core registers koa-bodyparser with a global REQUEST_BODY_LIMIT (10mb by
58
+ // default) much earlier in the stack, so without this the gateway's own
59
+ // configurable limit is unreachable: an oversized vision request would be
60
+ // rejected by core with a non-OpenAI error shape. `disableBodyParser` makes
61
+ // koa-bodyparser skip the request, leaving ctx.request.body undefined so
62
+ // createAiLlmRouter reads and caps the raw stream itself.
63
+ this.app.use(
64
+ async (ctx, next) => {
65
+ if (ctx.path.startsWith(AI_LLM_PREFIX)) ctx.disableBodyParser = true;
66
+ await next();
67
+ },
68
+ { tag: 'aiApiDisableBodyParser', before: 'bodyParser' },
69
+ );
70
+
71
+ // 2. Register raw Koa middleware for OpenAI-compatible endpoints
53
72
  // Must run before 'resourcer' so URL paths match OpenAI convention
54
73
  // OIDC access tokens must first pass through plugin-idp-oauth, which validates
55
74
  // issuer/audience/scope and rewrites them to a NocoBase internal token.
@@ -68,11 +87,12 @@ export class PluginAiApiServer extends Plugin {
68
87
 
69
88
  // 3. Set ACL permissions for admin config + role permissions management
70
89
  this.app.acl.registerSnippet({
71
- name: `pm.${this.name}.configuration`,
90
+ name: AI_API_ACL_SNIPPET,
72
91
  actions: [
73
92
  'aiApiConfig:*',
74
93
  'aiApiRolePermissions:*',
75
94
  'aiApiModelPrices:*',
95
+ 'aiApiModelMetadata:*',
76
96
  'aiApiUserQuotaPolicies:*',
77
97
  'aiApiUserQuotaBuckets:list',
78
98
  'aiApiUserQuotaBuckets:get',
@@ -8,6 +8,24 @@
8
8
  */
9
9
 
10
10
  import { ResourceOptions } from '@nocobase/resourcer';
11
+ import { MAX_REQUEST_BODY_MB_LIMIT } from '../routes/router';
12
+
13
+ const DEFAULT_MAX_REQUEST_BODY_MB = 10;
14
+
15
+ /**
16
+ * The gateway buffers each request body in memory, so an out-of-range value
17
+ * here is a denial-of-service footgun rather than a harmless setting.
18
+ */
19
+ function coerceMaxRequestBodyMb(value: unknown): number {
20
+ const mb = Number(value);
21
+ if (!Number.isSafeInteger(mb) || mb <= 0) {
22
+ throw new Error(`maxRequestBodyMb must be a positive integer (1-${MAX_REQUEST_BODY_MB_LIMIT}).`);
23
+ }
24
+ if (mb > MAX_REQUEST_BODY_MB_LIMIT) {
25
+ throw new Error(`maxRequestBodyMb cannot exceed ${MAX_REQUEST_BODY_MB_LIMIT}.`);
26
+ }
27
+ return mb;
28
+ }
11
29
 
12
30
  /**
13
31
  * Resource for managing AI API configuration via NocoBase admin UI.
@@ -29,6 +47,7 @@ const aiApiConfigResource: ResourceOptions = {
29
47
  defaultLlmService: '',
30
48
  enabledLlmServices: [],
31
49
  rateLimitPerMinute: 60,
50
+ maxRequestBodyMb: 10,
32
51
  quotaEnabled: false,
33
52
  defaultReservationOutputTokens: 4096,
34
53
  options: {},
@@ -52,6 +71,7 @@ const aiApiConfigResource: ResourceOptions = {
52
71
  defaultLlmService: values.defaultLlmService ?? '',
53
72
  enabledLlmServices: values.enabledLlmServices ?? [],
54
73
  rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
74
+ maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
55
75
  quotaEnabled: values.quotaEnabled ?? false,
56
76
  defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
57
77
  options: values.options ?? {},
@@ -64,6 +84,9 @@ const aiApiConfigResource: ResourceOptions = {
64
84
  if (values.defaultLlmService !== undefined) updateData.defaultLlmService = values.defaultLlmService;
65
85
  if (values.enabledLlmServices !== undefined) updateData.enabledLlmServices = values.enabledLlmServices;
66
86
  if (values.rateLimitPerMinute !== undefined) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
87
+ if (values.maxRequestBodyMb !== undefined) {
88
+ updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
89
+ }
67
90
  if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
68
91
  if (values.defaultReservationOutputTokens !== undefined) {
69
92
  updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
@@ -27,6 +27,7 @@ import {
27
27
  } from '../utils/ai-employee-runtime';
28
28
  import { setAiApiUsageUnavailable } from '../usage';
29
29
  import type PluginAiApiServer from '../plugin';
30
+ import { markAiApiFirstProviderOutput } from '../utils/app-observability';
30
31
 
31
32
  /**
32
33
  * POST /api/ai-llm/v1/chat/completions (agent mode)
@@ -281,85 +282,97 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
281
282
  // Don't actually end — we control this
282
283
  };
283
284
 
284
- // Intercept write() translate NocoBase SSE → OpenAI SSE
285
- (ctx.res as any).write = (data: Buffer | string): boolean => {
286
- pendingSse += typeof data === 'string' ? data : data.toString('utf8');
287
- const frames = pendingSse.split('\n\n');
288
- pendingSse = frames.pop() || '';
289
-
290
- for (const frame of frames) {
291
- for (const line of frame.split('\n')) {
292
- const trimmed = line.trim();
293
- if (!trimmed.startsWith('data: ')) continue;
294
-
295
- const jsonStr = trimmed.substring(6);
296
- if (!jsonStr) continue;
297
-
298
- try {
299
- const event = JSON.parse(jsonStr);
300
-
301
- if (event.type === 'content' && event.body) {
302
- // Content chunk — forward as OpenAI delta
285
+ // Translate one buffered NocoBase SSE frame → OpenAI SSE.
286
+ const processSseFrame = (frame: string): void => {
287
+ for (const line of frame.split('\n')) {
288
+ const trimmed = line.trim();
289
+ if (!trimmed.startsWith('data: ')) continue;
290
+
291
+ const jsonStr = trimmed.substring(6);
292
+ if (!jsonStr) continue;
293
+
294
+ try {
295
+ const event = JSON.parse(jsonStr);
296
+
297
+ if (event.type === 'content' && event.body) {
298
+ markAiApiFirstProviderOutput(ctx);
299
+ // Content chunk — forward as OpenAI delta
300
+ originalWrite(
301
+ formatSSE(
302
+ toOpenAIStreamChunk({
303
+ id: completionId,
304
+ model: body.model,
305
+ delta: { content: String(event.body) },
306
+ }),
307
+ ),
308
+ );
309
+ } else if (event.type === 'tool_call_chunks' && Array.isArray(event.body)) {
310
+ const chunks = toOpenAIToolCallChunks(event.body);
311
+ if (chunks.length) {
312
+ markAiApiFirstProviderOutput(ctx);
313
+ sawToolCalls = true;
303
314
  originalWrite(
304
315
  formatSSE(
305
316
  toOpenAIStreamChunk({
306
317
  id: completionId,
307
318
  model: body.model,
308
- delta: { content: String(event.body) },
319
+ delta: { tool_calls: chunks },
309
320
  }),
310
321
  ),
311
322
  );
312
- } else if (event.type === 'tool_call_chunks' && Array.isArray(event.body)) {
313
- const chunks = toOpenAIToolCallChunks(event.body);
314
- if (chunks.length) {
315
- sawToolCalls = true;
316
- originalWrite(
317
- formatSSE(
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
323
+ }
324
+ } else if (!sawToolCalls && event.type === 'tool_calls' && Array.isArray(event.body?.toolCalls)) {
325
+ const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
326
+ if (chunks.length) {
327
+ markAiApiFirstProviderOutput(ctx);
328
+ sawToolCalls = true;
342
329
  originalWrite(
343
- formatSSE({
344
- error: {
345
- message: String(event.body),
346
- type: 'server_error',
347
- code: 'agent_error',
348
- },
349
- }),
330
+ formatSSE(
331
+ toOpenAIStreamChunk({
332
+ id: completionId,
333
+ model: body.model,
334
+ delta: { tool_calls: chunks },
335
+ }),
336
+ ),
350
337
  );
351
338
  }
352
- // stream_start, stream_end, tool_call_status, web_search,
353
- // reasoning and new_message are NocoBase-only events and are ignored.
354
- // These are NocoBase-internal events not part of the OpenAI protocol.
355
- } catch {
356
- // Non-JSON SSE line — ignore
339
+ } else if (event.type === 'error' && event.body) {
340
+ // Error from the agent surface as SSE error object
341
+ originalWrite(
342
+ formatSSE({
343
+ error: {
344
+ message: String(event.body),
345
+ type: 'server_error',
346
+ code: 'agent_error',
347
+ },
348
+ }),
349
+ );
357
350
  }
351
+ // stream_start, stream_end, tool_call_status, web_search,
352
+ // reasoning and new_message are NocoBase-only events and are ignored.
353
+ // These are NocoBase-internal events not part of the OpenAI protocol.
354
+ } catch {
355
+ // Non-JSON SSE line — ignore
358
356
  }
359
357
  }
358
+ };
359
+
360
+ // Intercept write() — buffer input and translate complete frames.
361
+ (ctx.res as any).write = (data: Buffer | string): boolean => {
362
+ pendingSse += typeof data === 'string' ? data : data.toString('utf8');
363
+ const frames = pendingSse.split('\n\n');
364
+ pendingSse = frames.pop() || '';
365
+ for (const frame of frames) processSseFrame(frame);
360
366
  return true;
361
367
  };
362
368
 
369
+ // Flush any trailing frame that was not terminated by '\n\n'.
370
+ const flushPendingSse = (): void => {
371
+ if (!pendingSse.trim()) return;
372
+ processSseFrame(pendingSse);
373
+ pendingSse = '';
374
+ };
375
+
363
376
  try {
364
377
  const aiEmployee = new AIEmployee(
365
378
  createAIEmployeeOptions(ctx, employeeRecord, sessionId, {
@@ -385,6 +398,8 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
385
398
  ctx.res.off('close', abortAgent);
386
399
 
387
400
  if (streamSucceeded && !ctx.res.destroyed) {
401
+ // Emit any final frame the agent left unterminated before closing.
402
+ flushPendingSse();
388
403
  originalWrite(
389
404
  formatSSE(
390
405
  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
- ctx.state.currentRole = roleNames.includes(requestedRole) ? requestedRole : roleNames[0];
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;
@@ -19,11 +19,17 @@ import {
19
19
  OpenAIToolCallChunk,
20
20
  } from '../utils/openai-format';
21
21
  import { resolveModelString } from '../utils/resolve-service';
22
- import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
22
+ import {
23
+ createRequestAbortController,
24
+ isClientDisconnected,
25
+ isStreamingRequested,
26
+ writeResponse,
27
+ } from '../utils/streaming';
23
28
  import { checkEmployeeAccess } from '../middleware/role-permission';
24
29
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
25
30
  import type PluginAiApiServer from '../plugin';
26
31
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
32
+ import { markAiApiFirstProviderOutput } from '../utils/app-observability';
27
33
 
28
34
  /**
29
35
  * POST /api/ai-llm/v1/chat/completions
@@ -47,6 +53,30 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
47
53
  return;
48
54
  }
49
55
 
56
+ const messageProblem = findMessageProblem(body.messages);
57
+ if (messageProblem) {
58
+ ctx.status = 400;
59
+ ctx.body = toOpenAIError(
60
+ 400,
61
+ `Invalid messages[${messageProblem.index}]: ${messageProblem.reason}.`,
62
+ 'invalid_request_error',
63
+ 'invalid_message',
64
+ );
65
+ return;
66
+ }
67
+
68
+ const blockProblem = findContentBlockProblem(body.messages);
69
+ if (blockProblem) {
70
+ ctx.status = 400;
71
+ ctx.body = toOpenAIError(
72
+ 400,
73
+ `Invalid content block in messages[${blockProblem.index}]: ${blockProblem.reason}.`,
74
+ 'invalid_request_error',
75
+ 'invalid_content_block',
76
+ );
77
+ return;
78
+ }
79
+
50
80
  // ─── Reject unsupported n parameter ───
51
81
  if (body.n !== undefined && body.n !== null && body.n !== 1) {
52
82
  ctx.status = 400;
@@ -179,9 +209,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
179
209
  // ─── Build message tuples for LangChain model ───
180
210
  // LangChain chat models accept [role, content] tuples or BaseMessage objects.
181
211
  // We use tuples to avoid importing @langchain/core directly.
212
+ //
213
+ // `content` may be a string OR an array of content blocks (OpenAI vision
214
+ // format: [{type:'text'}, {type:'image_url', image_url:{url:'data:...'}}]).
215
+ // Arrays must be forwarded structurally — stringifying them would turn an
216
+ // image into literal JSON text and the model would never see the picture.
182
217
  const langchainMessages = messages.map((msg: any) => {
183
218
  const role = msg.role === 'assistant' ? 'ai' : msg.role;
184
- const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
219
+ const content = normalizeMessageContent(msg.content);
185
220
  if (msg.role === 'assistant' && msg.tool_calls) {
186
221
  return {
187
222
  role,
@@ -193,7 +228,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
193
228
  if (msg.role === 'tool') {
194
229
  return { role: 'tool', content, tool_call_id: msg.tool_call_id, name: msg.name };
195
230
  }
196
- return [role, content] as [string, string];
231
+ return [role, content] as [string, MessageContent];
197
232
  });
198
233
 
199
234
  const completionId = generateCompletionId();
@@ -326,6 +361,7 @@ async function handleStreamingCompletion(
326
361
  }
327
362
 
328
363
  if (content) {
364
+ markAiApiFirstProviderOutput(ctx);
329
365
  await writeResponse(
330
366
  ctx,
331
367
  formatSSE(
@@ -340,6 +376,7 @@ async function handleStreamingCompletion(
340
376
 
341
377
  const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
342
378
  if (toolCallChunks.length) {
379
+ markAiApiFirstProviderOutput(ctx);
343
380
  finishReason = 'tool_calls';
344
381
  await writeResponse(
345
382
  ctx,
@@ -370,6 +407,7 @@ async function handleStreamingCompletion(
370
407
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
371
408
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
372
409
  } catch (err) {
410
+ const cancelled = isClientDisconnected(ctx, err);
373
411
  ctx.log.error('AI API streaming error:', err);
374
412
  // Send error as SSE event before closing
375
413
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
@@ -384,7 +422,11 @@ async function handleStreamingCompletion(
384
422
  );
385
423
  }
386
424
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
387
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'stream_error' };
425
+ ctx.state.aiApiStreamResult = {
426
+ succeeded: false,
427
+ id: completionId,
428
+ errorCode: cancelled ? 'client_disconnected' : 'stream_error',
429
+ };
388
430
  } finally {
389
431
  requestAbort.dispose();
390
432
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -395,6 +437,222 @@ function getErrorMessage(error: unknown, fallback: string) {
395
437
  return error instanceof Error && error.message ? error.message : fallback;
396
438
  }
397
439
 
440
+ /**
441
+ * Content block types every provider adapter in `@nocobase/plugin-ai` maps to a
442
+ * native equivalent.
443
+ *
444
+ * Anything else is rejected rather than forwarded: the LangChain block
445
+ * converters are if/else-if chains with no fallback branch, so an unrecognized
446
+ * block (OpenAI's `{type:'file'}` on Anthropic, for example) yields nothing and
447
+ * the model answers as if the attachment was never sent. A 400 is far easier to
448
+ * debug than a confidently wrong completion.
449
+ */
450
+ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
451
+
452
+ /**
453
+ * Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
454
+ * accepts (`\w+/\w+`, standard base64). A looser pattern here would admit URLs
455
+ * the provider adapter then fails to parse — it falls through to `new URL()`,
456
+ * sees the `data:` protocol and throws, which reaches the client as a 500.
457
+ * Compound subtypes such as `image/svg+xml` are rejected for that reason.
458
+ */
459
+ const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
460
+
461
+ /**
462
+ * The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
463
+ * `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
464
+ * calls `atob` on the payload, which throws a DOMException for each of them, and
465
+ * that escapes as an HTTP 500. Re-encoding is the cheapest exact check — the
466
+ * canonical form of a valid payload is the payload itself.
467
+ */
468
+ function isDecodableBase64(payload: string): boolean {
469
+ try {
470
+ return Buffer.from(payload, 'base64').toString('base64') === payload;
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+
476
+ export interface ContentBlockProblem {
477
+ index: number;
478
+ reason: string;
479
+ }
480
+
481
+ /**
482
+ * Roles `_constructMessageFromParams` can turn into a message. Anything else
483
+ * reaches its final `else` and throws MESSAGE_COERCION_FAILURE.
484
+ *
485
+ * `function` is deliberately absent: OpenAI deprecated it, and LangChain has no
486
+ * branch for it despite mapping the class name internally.
487
+ */
488
+ const SUPPORTED_MESSAGE_ROLES = new Set(['system', 'developer', 'user', 'human', 'assistant', 'ai', 'tool']);
489
+
490
+ /**
491
+ * Validate the shape of each message before any provider work happens.
492
+ *
493
+ * Requiring only "non-empty array" lets `messages: [null]` through to
494
+ * `messages.some((m) => m.role === 'system')`, which throws a TypeError and is
495
+ * reported as a 500. An unsupported role travels further still and dies inside
496
+ * LangChain with MESSAGE_COERCION_FAILURE. Both are caller errors, so both
497
+ * should be a 400 that names the offending index.
498
+ */
499
+ export function findMessageProblem(messages: unknown[]): ContentBlockProblem | undefined {
500
+ for (const [index, message] of messages.entries()) {
501
+ if (!isRecord(message)) return { index, reason: 'each message must be an object' };
502
+
503
+ const role = typeof message.role === 'string' ? message.role : undefined;
504
+ if (!role) return { index, reason: "each message requires a string 'role' field" };
505
+ if (!SUPPORTED_MESSAGE_ROLES.has(role)) {
506
+ return {
507
+ index,
508
+ reason: `role '${role}' is not supported — use one of ` + `${[...SUPPORTED_MESSAGE_ROLES].join(', ')}`,
509
+ };
510
+ }
511
+
512
+ if (role === 'tool' && typeof message.tool_call_id !== 'string') {
513
+ return { index, reason: "a 'tool' message requires a string 'tool_call_id' field" };
514
+ }
515
+
516
+ const { content } = message;
517
+ const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
518
+ if (content === undefined || content === null) {
519
+ // An assistant turn that only calls tools legitimately carries no content.
520
+ if ((role === 'assistant' || role === 'ai') && hasToolCalls) continue;
521
+ return { index, reason: "each message requires a 'content' field" };
522
+ }
523
+ if (typeof content !== 'string' && !Array.isArray(content)) {
524
+ return { index, reason: "'content' must be a string or an array of content blocks" };
525
+ }
526
+ }
527
+ return undefined;
528
+ }
529
+
530
+ /**
531
+ * Validate the multimodal content blocks of a chat request.
532
+ *
533
+ * Checking `type` alone is not enough. The provider adapters either drop or
534
+ * throw on malformed blocks, and both outcomes surface badly:
535
+ *
536
+ * - A block whose payload fails every branch of the converter yields nothing,
537
+ * so the model answers as if the attachment was never sent.
538
+ * - `_formatImage` throws on a malformed or non-http(s) URL, and that escapes as
539
+ * a generic HTTP 500 instead of telling the caller what was wrong.
540
+ * - `parseBase64DataUrl` matches any `data:<type>/<subtype>;base64,` URL without
541
+ * checking for `image/*`, so a PDF becomes an `image` block with
542
+ * `media_type: application/pdf` that the model cannot read.
543
+ *
544
+ * Validating up front turns all of those into an actionable 400.
545
+ */
546
+ export function findContentBlockProblem(messages: unknown[]): ContentBlockProblem | undefined {
547
+ for (const [index, message] of messages.entries()) {
548
+ const content = isRecord(message) ? message.content : undefined;
549
+ if (!Array.isArray(content)) continue;
550
+ for (const block of content) {
551
+ if (typeof block === 'string') continue;
552
+ const reason = describeContentBlockProblem(block);
553
+ if (reason) return { index, reason };
554
+ }
555
+ }
556
+ return undefined;
557
+ }
558
+
559
+ function describeContentBlockProblem(block: unknown): string | undefined {
560
+ if (!isRecord(block)) return 'each content block must be an object';
561
+
562
+ const type = typeof block.type === 'string' ? block.type : undefined;
563
+ if (!type) return "each content block requires a 'type' field";
564
+ if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
565
+ return (
566
+ `content block type '${type}' is not supported — this gateway forwards 'text' and 'image_url' only. ` +
567
+ `Send documents as text, or inline them as an 'image_url' data URL if the model reads images`
568
+ );
569
+ }
570
+
571
+ if (type === 'text') {
572
+ return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
573
+ }
574
+
575
+ return describeImageUrlProblem(block.image_url);
576
+ }
577
+
578
+ function describeImageUrlProblem(imageUrl: unknown): string | undefined {
579
+ const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
580
+ if (typeof url !== 'string' || url === '') {
581
+ return "an 'image_url' block requires a non-empty 'image_url.url' string";
582
+ }
583
+
584
+ if (url.startsWith('data:')) {
585
+ const match = BASE64_DATA_URL_PATTERN.exec(url);
586
+ if (!match) {
587
+ return (
588
+ `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' ` +
589
+ `with standard base64 (no whitespace or URL-safe characters)`
590
+ );
591
+ }
592
+ const mimeType = match[1].toLowerCase();
593
+ if (!mimeType.startsWith('image/')) {
594
+ return (
595
+ `data URL MIME type '${mimeType}' is not an image. Only 'image/*' data URLs are forwarded, ` +
596
+ `because providers reject or ignore other types on an 'image_url' block`
597
+ );
598
+ }
599
+ if (!isDecodableBase64(match[2])) {
600
+ return (
601
+ `base64 payload is not decodable. Check the padding and length — ` +
602
+ `the data must be a multiple of 4 characters with at most two trailing '='`
603
+ );
604
+ }
605
+ return undefined;
606
+ }
607
+
608
+ let protocol: string;
609
+ try {
610
+ protocol = new URL(url).protocol;
611
+ } catch {
612
+ return `'${url}' is not a valid URL. Use an http(s) URL or a base64 data URL`;
613
+ }
614
+ if (protocol !== 'http:' && protocol !== 'https:') {
615
+ return `URL protocol '${protocol}' is not supported. Use an http(s) URL or a base64 data URL`;
616
+ }
617
+ return undefined;
618
+ }
619
+
620
+ /**
621
+ * A LangChain message content value: plain text, or an array of content blocks
622
+ * (`{type:'text'}`, `{type:'image_url'}`, ...) for multimodal requests.
623
+ */
624
+ type MessageContent = string | Record<string, unknown>[];
625
+
626
+ /**
627
+ * Normalize an OpenAI `message.content` into something LangChain accepts.
628
+ *
629
+ * Content blocks are passed through unchanged so vision requests reach the
630
+ * provider intact — `@langchain/core` coerces `image_url` blocks into the
631
+ * provider's native format. Only genuinely unusable shapes (numbers, objects)
632
+ * are stringified as a last resort.
633
+ *
634
+ * The one rewrite is `image_url: '<url>'` → `image_url: { url: '<url>' }`.
635
+ * `isOpenAIDataBlock` gates on `_isObject(block.image_url)`, so the string form
636
+ * is never recognised as a data block: core forwards it untouched and only
637
+ * Anthropic's adapter happens to accept it. Widening it here keeps the lenient
638
+ * request working on every provider instead of just one.
639
+ */
640
+ export function normalizeMessageContent(content: unknown): MessageContent {
641
+ if (typeof content === 'string') return content;
642
+ if (Array.isArray(content)) {
643
+ return content.map((block) => {
644
+ if (typeof block === 'string') return { type: 'text', text: block };
645
+ const record = block as Record<string, unknown>;
646
+ if (record?.type === 'image_url' && typeof record.image_url === 'string') {
647
+ return { ...record, image_url: { url: record.image_url } };
648
+ }
649
+ return record;
650
+ });
651
+ }
652
+ if (content === null || content === undefined) return '';
653
+ return JSON.stringify(content);
654
+ }
655
+
398
656
  const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'tools', 'tool_choice', 'stream', 'n']);
399
657
 
400
658
  export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {