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.
Files changed (91) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  3. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  4. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  5. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  6. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  7. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  10. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  11. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  12. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  13. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  14. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  15. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  16. package/dist/client-v2/index.js +1 -1
  17. package/dist/constants.js +39 -0
  18. package/dist/externalVersion.js +9 -10
  19. package/dist/locale/en-US.json +39 -9
  20. package/dist/locale/vi-VN.json +31 -1
  21. package/dist/locale/zh-CN.json +31 -1
  22. package/dist/server/collections/ai-api-config.js +6 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  24. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  25. package/dist/server/plugin.js +45 -1
  26. package/dist/server/resource/ai-api-config.js +17 -0
  27. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  28. package/dist/server/routes/agent-completions.js +67 -51
  29. package/dist/server/routes/auth.js +11 -1
  30. package/dist/server/routes/chat-completions.js +174 -20
  31. package/dist/server/routes/completions.js +41 -21
  32. package/dist/server/routes/embeddings.js +6 -14
  33. package/dist/server/routes/models.js +102 -20
  34. package/dist/server/routes/router.js +94 -22
  35. package/dist/server/usage.js +2 -0
  36. package/dist/server/utils/app-observability.js +110 -0
  37. package/dist/server/utils/openai-format.js +17 -3
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/utils/user-permissions.js +160 -0
  40. package/dist/server/validation.js +18 -0
  41. package/dist/swagger.js +36 -4
  42. package/package.json +2 -2
  43. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  44. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  45. package/src/client/locale.ts +11 -21
  46. package/src/client/plugin.tsx +28 -8
  47. package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
  48. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  49. package/src/client-v2/locale.ts +21 -1
  50. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  51. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  52. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  53. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  54. package/src/client-v2/plugin.tsx +50 -1
  55. package/src/constants.ts +28 -0
  56. package/src/locale/en-US.json +39 -9
  57. package/src/locale/vi-VN.json +31 -1
  58. package/src/locale/zh-CN.json +31 -1
  59. package/src/server/__tests__/app-observability.test.ts +98 -0
  60. package/src/server/__tests__/models.test.ts +116 -0
  61. package/src/server/__tests__/openai-format.test.ts +52 -1
  62. package/src/server/__tests__/permission-sync.test.ts +109 -0
  63. package/src/server/__tests__/request-body.test.ts +310 -0
  64. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  65. package/src/server/__tests__/usage-route.test.ts +213 -0
  66. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  67. package/src/server/__tests__/user-permissions.test.ts +284 -0
  68. package/src/server/collections/ai-api-config.ts +6 -0
  69. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  70. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  71. package/src/server/plugin.ts +65 -4
  72. package/src/server/resource/ai-api-config.ts +23 -0
  73. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  74. package/src/server/routes/agent-completions.ts +84 -62
  75. package/src/server/routes/auth.ts +14 -1
  76. package/src/server/routes/chat-completions.ts +294 -20
  77. package/src/server/routes/completions.ts +54 -20
  78. package/src/server/routes/embeddings.ts +10 -15
  79. package/src/server/routes/models.ts +318 -195
  80. package/src/server/routes/router.ts +136 -26
  81. package/src/server/usage.ts +2 -0
  82. package/src/server/utils/app-observability.ts +105 -0
  83. package/src/server/utils/openai-format.ts +26 -0
  84. package/src/server/utils/streaming.ts +13 -1
  85. package/src/server/utils/user-permissions.ts +218 -0
  86. package/src/server/validation.ts +27 -0
  87. package/src/swagger.ts +47 -4
  88. package/dist/client/302.25edd5d75460acbf.js +0 -10
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  91. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -29,6 +29,18 @@ __export(ai_api_config_exports, {
29
29
  default: () => ai_api_config_default
30
30
  });
31
31
  module.exports = __toCommonJS(ai_api_config_exports);
32
+ var import_router = require("../routes/router");
33
+ const DEFAULT_MAX_REQUEST_BODY_MB = 10;
34
+ function coerceMaxRequestBodyMb(value) {
35
+ const mb = Number(value);
36
+ if (!Number.isSafeInteger(mb) || mb <= 0) {
37
+ throw new Error(`maxRequestBodyMb must be a positive integer (1-${import_router.MAX_REQUEST_BODY_MB_LIMIT}).`);
38
+ }
39
+ if (mb > import_router.MAX_REQUEST_BODY_MB_LIMIT) {
40
+ throw new Error(`maxRequestBodyMb cannot exceed ${import_router.MAX_REQUEST_BODY_MB_LIMIT}.`);
41
+ }
42
+ return mb;
43
+ }
32
44
  const aiApiConfigResource = {
33
45
  name: "aiApiConfig",
34
46
  actions: {
@@ -42,6 +54,7 @@ const aiApiConfigResource = {
42
54
  defaultLlmService: "",
43
55
  enabledLlmServices: [],
44
56
  rateLimitPerMinute: 60,
57
+ maxRequestBodyMb: 10,
45
58
  quotaEnabled: false,
46
59
  defaultReservationOutputTokens: 4096,
47
60
  options: {}
@@ -63,6 +76,7 @@ const aiApiConfigResource = {
63
76
  defaultLlmService: values.defaultLlmService ?? "",
64
77
  enabledLlmServices: values.enabledLlmServices ?? [],
65
78
  rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
79
+ maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
66
80
  quotaEnabled: values.quotaEnabled ?? false,
67
81
  defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
68
82
  options: values.options ?? {}
@@ -75,6 +89,9 @@ const aiApiConfigResource = {
75
89
  if (values.defaultLlmService !== void 0) updateData.defaultLlmService = values.defaultLlmService;
76
90
  if (values.enabledLlmServices !== void 0) updateData.enabledLlmServices = values.enabledLlmServices;
77
91
  if (values.rateLimitPerMinute !== void 0) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
92
+ if (values.maxRequestBodyMb !== void 0) {
93
+ updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
94
+ }
78
95
  if (values.quotaEnabled !== void 0) updateData.quotaEnabled = values.quotaEnabled;
79
96
  if (values.defaultReservationOutputTokens !== void 0) {
80
97
  updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var ai_api_user_permissions_exports = {};
28
+ __export(ai_api_user_permissions_exports, {
29
+ default: () => ai_api_user_permissions_default
30
+ });
31
+ module.exports = __toCommonJS(ai_api_user_permissions_exports);
32
+ const MAX_PAGE_SIZE = 100;
33
+ const aiApiUserPermissionsResource = {
34
+ name: "aiApiUserPermissions",
35
+ actions: {
36
+ async listUsers(ctx, next) {
37
+ const params = ctx.action.params || {};
38
+ const keyword = typeof params.keyword === "string" ? params.keyword.trim() : "";
39
+ const page = Math.max(1, Number(params.page) || 1);
40
+ const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(params.pageSize) || 50));
41
+ const filter = keyword ? {
42
+ $or: [
43
+ { nickname: { $includes: keyword } },
44
+ { username: { $includes: keyword } },
45
+ { email: { $includes: keyword } }
46
+ ]
47
+ } : {};
48
+ if (params.excludeGranted) {
49
+ const granted = await ctx.db.getRepository("aiApiUserPermissions").find({ fields: ["userId"] });
50
+ const ids = granted.map((row) => row.get("userId")).filter((id) => id !== null && id !== void 0);
51
+ if (ids.length) filter.id = { $notIn: ids };
52
+ }
53
+ const [rows, count] = await ctx.db.getRepository("users").findAndCount({
54
+ filter,
55
+ fields: ["id", "nickname", "username", "email"],
56
+ sort: ["nickname", "id"],
57
+ offset: (page - 1) * pageSize,
58
+ limit: pageSize
59
+ });
60
+ ctx.body = {
61
+ rows: rows.map((row) => ({
62
+ id: row.get("id"),
63
+ nickname: row.get("nickname"),
64
+ username: row.get("username"),
65
+ email: row.get("email")
66
+ })),
67
+ count,
68
+ page,
69
+ pageSize
70
+ };
71
+ await next();
72
+ }
73
+ }
74
+ };
75
+ var ai_api_user_permissions_default = aiApiUserPermissionsResource;
@@ -32,9 +32,11 @@ module.exports = __toCommonJS(agent_completions_exports);
32
32
  var import_openai_format = require("../utils/openai-format");
33
33
  var import_resolve_service = require("../utils/resolve-service");
34
34
  var import_role_permission = require("../middleware/role-permission");
35
+ var import_user_permissions = require("../utils/user-permissions");
35
36
  var import_streaming = require("../utils/streaming");
36
37
  var import_ai_employee_runtime = require("../utils/ai-employee-runtime");
37
38
  var import_usage = require("../usage");
39
+ var import_app_observability = require("../utils/app-observability");
38
40
  async function handleAgentCompletions(ctx, plugin) {
39
41
  var _a;
40
42
  const body = ctx.request.body;
@@ -87,6 +89,10 @@ async function handleAgentCompletions(ctx, plugin) {
87
89
  ctx.body = (0, import_openai_format.toOpenAIError)(404, "LLM service is disabled", "invalid_request_error", "model_not_found");
88
90
  return;
89
91
  }
92
+ const globalEnabledServices = config ? config.get("enabledLlmServices") || config.enabledLlmServices : [];
93
+ if (!await (0, import_user_permissions.enforceModelAccess)(ctx, globalEnabledServices, service, modelId)) {
94
+ return;
95
+ }
90
96
  const employeeUsername = defaultAiEmployee;
91
97
  if (!(0, import_role_permission.checkEmployeeAccess)(ctx, employeeUsername)) {
92
98
  ctx.status = 403;
@@ -201,74 +207,83 @@ async function handleAgentCompletions(ctx, plugin) {
201
207
  ctx.res.write(args[0]);
202
208
  }
203
209
  };
204
- ctx.res.write = (data) => {
210
+ const processSseFrame = (frame) => {
205
211
  var _a2;
206
- pendingSse += typeof data === "string" ? data : data.toString("utf8");
207
- const frames = pendingSse.split("\n\n");
208
- pendingSse = frames.pop() || "";
209
- for (const frame of frames) {
210
- for (const line of frame.split("\n")) {
211
- const trimmed = line.trim();
212
- if (!trimmed.startsWith("data: ")) continue;
213
- const jsonStr = trimmed.substring(6);
214
- if (!jsonStr) continue;
215
- try {
216
- const event = JSON.parse(jsonStr);
217
- if (event.type === "content" && event.body) {
212
+ for (const line of frame.split("\n")) {
213
+ const trimmed = line.trim();
214
+ if (!trimmed.startsWith("data: ")) continue;
215
+ const jsonStr = trimmed.substring(6);
216
+ if (!jsonStr) continue;
217
+ try {
218
+ const event = JSON.parse(jsonStr);
219
+ if (event.type === "content" && event.body) {
220
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
221
+ originalWrite(
222
+ (0, import_openai_format.formatSSE)(
223
+ (0, import_openai_format.toOpenAIStreamChunk)({
224
+ id: completionId,
225
+ model: body.model,
226
+ delta: { content: String(event.body) }
227
+ })
228
+ )
229
+ );
230
+ } else if (event.type === "tool_call_chunks" && Array.isArray(event.body)) {
231
+ const chunks = toOpenAIToolCallChunks(event.body);
232
+ if (chunks.length) {
233
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
234
+ sawToolCalls = true;
218
235
  originalWrite(
219
236
  (0, import_openai_format.formatSSE)(
220
237
  (0, import_openai_format.toOpenAIStreamChunk)({
221
238
  id: completionId,
222
239
  model: body.model,
223
- delta: { content: String(event.body) }
240
+ delta: { tool_calls: chunks }
224
241
  })
225
242
  )
226
243
  );
227
- } else if (event.type === "tool_call_chunks" && Array.isArray(event.body)) {
228
- const chunks = toOpenAIToolCallChunks(event.body);
229
- if (chunks.length) {
230
- sawToolCalls = true;
231
- originalWrite(
232
- (0, import_openai_format.formatSSE)(
233
- (0, import_openai_format.toOpenAIStreamChunk)({
234
- id: completionId,
235
- model: body.model,
236
- delta: { tool_calls: chunks }
237
- })
238
- )
239
- );
240
- }
241
- } else if (!sawToolCalls && event.type === "tool_calls" && Array.isArray((_a2 = event.body) == null ? void 0 : _a2.toolCalls)) {
242
- const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
243
- if (chunks.length) {
244
- sawToolCalls = true;
245
- originalWrite(
246
- (0, import_openai_format.formatSSE)(
247
- (0, import_openai_format.toOpenAIStreamChunk)({
248
- id: completionId,
249
- model: body.model,
250
- delta: { tool_calls: chunks }
251
- })
252
- )
253
- );
254
- }
255
- } else if (event.type === "error" && event.body) {
244
+ }
245
+ } else if (!sawToolCalls && event.type === "tool_calls" && Array.isArray((_a2 = event.body) == null ? void 0 : _a2.toolCalls)) {
246
+ const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
247
+ if (chunks.length) {
248
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
249
+ sawToolCalls = true;
256
250
  originalWrite(
257
- (0, import_openai_format.formatSSE)({
258
- error: {
259
- message: String(event.body),
260
- type: "server_error",
261
- code: "agent_error"
262
- }
263
- })
251
+ (0, import_openai_format.formatSSE)(
252
+ (0, import_openai_format.toOpenAIStreamChunk)({
253
+ id: completionId,
254
+ model: body.model,
255
+ delta: { tool_calls: chunks }
256
+ })
257
+ )
264
258
  );
265
259
  }
266
- } catch {
260
+ } else if (event.type === "error" && event.body) {
261
+ originalWrite(
262
+ (0, import_openai_format.formatSSE)({
263
+ error: {
264
+ message: String(event.body),
265
+ type: "server_error",
266
+ code: "agent_error"
267
+ }
268
+ })
269
+ );
267
270
  }
271
+ } catch {
268
272
  }
269
273
  }
274
+ };
275
+ ctx.res.write = (data) => {
276
+ pendingSse += typeof data === "string" ? data : data.toString("utf8");
277
+ const frames = pendingSse.split("\n\n");
278
+ pendingSse = frames.pop() || "";
279
+ for (const frame of frames) processSseFrame(frame);
270
280
  return true;
271
281
  };
282
+ const flushPendingSse = () => {
283
+ if (!pendingSse.trim()) return;
284
+ processSseFrame(pendingSse);
285
+ pendingSse = "";
286
+ };
272
287
  try {
273
288
  const aiEmployee = new AIEmployee(
274
289
  (0, import_ai_employee_runtime.createAIEmployeeOptions)(ctx, employeeRecord, sessionId, {
@@ -291,6 +306,7 @@ async function handleAgentCompletions(ctx, plugin) {
291
306
  ctx.req.off("aborted", abortAgent);
292
307
  ctx.res.off("close", abortAgent);
293
308
  if (streamSucceeded && !ctx.res.destroyed) {
309
+ flushPendingSse();
294
310
  originalWrite(
295
311
  (0, import_openai_format.formatSSE)(
296
312
  (0, import_openai_format.toOpenAIStreamChunk)({
@@ -57,7 +57,17 @@ async function authenticateBearer(ctx) {
57
57
  const rolesRepository2 = ctx.db.getRepository("users.roles", ctx.state.currentUser.id);
58
58
  const roles2 = await rolesRepository2.find({ fields: ["name"] });
59
59
  const roleNames2 = roles2.map((role) => role.name);
60
- ctx.state.currentRole = roleNames2.includes(requestedRole) ? requestedRole : roleNames2[0];
60
+ if (requestedRole && !roleNames2.includes(requestedRole)) {
61
+ ctx.status = 403;
62
+ ctx.body = (0, import_openai_format.toOpenAIError)(
63
+ 403,
64
+ `Requested role '${requestedRole}' is not assigned to this user`,
65
+ "permission_denied",
66
+ "role_not_permitted"
67
+ );
68
+ return false;
69
+ }
70
+ ctx.state.currentRole = requestedRole || roleNames2[0];
61
71
  ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames2;
62
72
  }
63
73
  return true;
@@ -27,18 +27,22 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
27
27
  var chat_completions_exports = {};
28
28
  __export(chat_completions_exports, {
29
29
  applyProviderRequestParameters: () => applyProviderRequestParameters,
30
+ findContentBlockProblem: () => findContentBlockProblem,
31
+ findMessageProblem: () => findMessageProblem,
30
32
  getProviderRequestParameters: () => getProviderRequestParameters,
31
- handleChatCompletions: () => handleChatCompletions
33
+ handleChatCompletions: () => handleChatCompletions,
34
+ normalizeMessageContent: () => normalizeMessageContent
32
35
  });
33
36
  module.exports = __toCommonJS(chat_completions_exports);
34
37
  var import_openai_format = require("../utils/openai-format");
35
38
  var import_resolve_service = require("../utils/resolve-service");
36
39
  var import_streaming = require("../utils/streaming");
37
40
  var import_role_permission = require("../middleware/role-permission");
41
+ var import_user_permissions = require("../utils/user-permissions");
38
42
  var import_usage = require("../usage");
39
43
  var import_billing = require("../billing");
44
+ var import_app_observability = require("../utils/app-observability");
40
45
  async function handleChatCompletions(ctx, plugin) {
41
- var _a;
42
46
  const body = ctx.request.body;
43
47
  if (!(body == null ? void 0 : body.model)) {
44
48
  ctx.status = 400;
@@ -50,6 +54,28 @@ async function handleChatCompletions(ctx, plugin) {
50
54
  ctx.body = (0, import_openai_format.toOpenAIError)(400, "'messages' must be a non-empty array", "invalid_request_error", "missing_messages");
51
55
  return;
52
56
  }
57
+ const messageProblem = findMessageProblem(body.messages);
58
+ if (messageProblem) {
59
+ ctx.status = 400;
60
+ ctx.body = (0, import_openai_format.toOpenAIError)(
61
+ 400,
62
+ `Invalid messages[${messageProblem.index}]: ${messageProblem.reason}.`,
63
+ "invalid_request_error",
64
+ "invalid_message"
65
+ );
66
+ return;
67
+ }
68
+ const blockProblem = findContentBlockProblem(body.messages);
69
+ if (blockProblem) {
70
+ ctx.status = 400;
71
+ ctx.body = (0, import_openai_format.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
+ }
53
79
  if (body.n !== void 0 && body.n !== null && body.n !== 1) {
54
80
  ctx.status = 400;
55
81
  ctx.body = (0, import_openai_format.toOpenAIError)(
@@ -91,20 +117,8 @@ async function handleChatCompletions(ctx, plugin) {
91
117
  return;
92
118
  }
93
119
  const config = await ctx.db.getRepository("aiApiConfig").findOne();
94
- if ((_a = config == null ? void 0 : config.enabledLlmServices) == null ? void 0 : _a.length) {
95
- const serviceName = service.name;
96
- const serviceTitle = service.title;
97
- const isAllowed = config.enabledLlmServices.some((s) => s === serviceName || s === serviceTitle);
98
- if (!isAllowed) {
99
- ctx.status = 403;
100
- ctx.body = (0, import_openai_format.toOpenAIError)(
101
- 403,
102
- `LLM service '${service.title || service.name}' is not enabled for API access`,
103
- "invalid_request_error",
104
- "model_not_available"
105
- );
106
- return;
107
- }
120
+ if (!await (0, import_user_permissions.enforceModelAccess)(ctx, config == null ? void 0 : config.enabledLlmServices, service, modelId)) {
121
+ return;
108
122
  }
109
123
  const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
110
124
  if (!providerMeta) {
@@ -114,6 +128,13 @@ async function handleChatCompletions(ctx, plugin) {
114
128
  }
115
129
  await (0, import_billing.prepareLlmBilling)(ctx, resolved);
116
130
  const providerRequestParameters = getProviderRequestParameters(body);
131
+ if (stream) {
132
+ const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
133
+ providerRequestParameters.stream_options = {
134
+ ...streamOptions,
135
+ include_usage: true
136
+ };
137
+ }
117
138
  const modelOptions = {
118
139
  model: modelId,
119
140
  llmService: service.name
@@ -157,7 +178,7 @@ async function handleChatCompletions(ctx, plugin) {
157
178
  }
158
179
  const langchainMessages = messages.map((msg) => {
159
180
  const role = msg.role === "assistant" ? "ai" : msg.role;
160
- const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
181
+ const content = normalizeMessageContent(msg.content);
161
182
  if (msg.role === "assistant" && msg.tool_calls) {
162
183
  return {
163
184
  role,
@@ -268,6 +289,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
268
289
  content = (textPart == null ? void 0 : textPart.text) || "";
269
290
  }
270
291
  if (content) {
292
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
271
293
  await (0, import_streaming.writeResponse)(
272
294
  ctx,
273
295
  (0, import_openai_format.formatSSE)(
@@ -281,10 +303,17 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
281
303
  }
282
304
  const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
283
305
  if (toolCallChunks.length) {
306
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
284
307
  finishReason = "tool_calls";
285
308
  await (0, import_streaming.writeResponse)(
286
309
  ctx,
287
- (0, import_openai_format.formatSSE)((0, import_openai_format.toOpenAIStreamChunk)({ id: completionId, model: modelName, delta: { tool_calls: toolCallChunks } }))
310
+ (0, import_openai_format.formatSSE)(
311
+ (0, import_openai_format.toOpenAIStreamChunk)({
312
+ id: completionId,
313
+ model: modelName,
314
+ delta: { tool_calls: toolCallChunks }
315
+ })
316
+ )
288
317
  );
289
318
  }
290
319
  if (chunk.usage_metadata) {
@@ -303,10 +332,23 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
303
332
  })
304
333
  )
305
334
  );
335
+ if (usage) {
336
+ await (0, import_streaming.writeResponse)(
337
+ ctx,
338
+ (0, import_openai_format.formatSSE)(
339
+ (0, import_openai_format.toOpenAIUsageChunk)({
340
+ id: completionId,
341
+ model: modelName,
342
+ usage
343
+ })
344
+ )
345
+ );
346
+ }
306
347
  await (0, import_streaming.writeResponse)(ctx, (0, import_openai_format.formatSSEDone)());
307
348
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
308
349
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
309
350
  } catch (err) {
351
+ const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
310
352
  ctx.log.error("AI API streaming error:", err);
311
353
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
312
354
  await (0, import_streaming.writeResponse)(
@@ -320,7 +362,11 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
320
362
  );
321
363
  }
322
364
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
323
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: "stream_error" };
365
+ ctx.state.aiApiStreamResult = {
366
+ succeeded: false,
367
+ id: completionId,
368
+ errorCode: cancelled ? "client_disconnected" : "stream_error"
369
+ };
324
370
  } finally {
325
371
  requestAbort.dispose();
326
372
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -329,6 +375,111 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
329
375
  function getErrorMessage(error, fallback) {
330
376
  return error instanceof Error && error.message ? error.message : fallback;
331
377
  }
378
+ const SUPPORTED_CONTENT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "image_url"]);
379
+ const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
380
+ function isDecodableBase64(payload) {
381
+ try {
382
+ return Buffer.from(payload, "base64").toString("base64") === payload;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+ const SUPPORTED_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "developer", "user", "human", "assistant", "ai", "tool"]);
388
+ function findMessageProblem(messages) {
389
+ for (const [index, message] of messages.entries()) {
390
+ if (!isRecord(message)) return { index, reason: "each message must be an object" };
391
+ const role = typeof message.role === "string" ? message.role : void 0;
392
+ if (!role) return { index, reason: "each message requires a string 'role' field" };
393
+ if (!SUPPORTED_MESSAGE_ROLES.has(role)) {
394
+ return {
395
+ index,
396
+ reason: `role '${role}' is not supported \u2014 use one of ${[...SUPPORTED_MESSAGE_ROLES].join(", ")}`
397
+ };
398
+ }
399
+ if (role === "tool" && typeof message.tool_call_id !== "string") {
400
+ return { index, reason: "a 'tool' message requires a string 'tool_call_id' field" };
401
+ }
402
+ const { content } = message;
403
+ const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
404
+ if (content === void 0 || content === null) {
405
+ if ((role === "assistant" || role === "ai") && hasToolCalls) continue;
406
+ return { index, reason: "each message requires a 'content' field" };
407
+ }
408
+ if (typeof content !== "string" && !Array.isArray(content)) {
409
+ return { index, reason: "'content' must be a string or an array of content blocks" };
410
+ }
411
+ }
412
+ return void 0;
413
+ }
414
+ function findContentBlockProblem(messages) {
415
+ for (const [index, message] of messages.entries()) {
416
+ const content = isRecord(message) ? message.content : void 0;
417
+ if (!Array.isArray(content)) continue;
418
+ for (const block of content) {
419
+ if (typeof block === "string") continue;
420
+ const reason = describeContentBlockProblem(block);
421
+ if (reason) return { index, reason };
422
+ }
423
+ }
424
+ return void 0;
425
+ }
426
+ function describeContentBlockProblem(block) {
427
+ if (!isRecord(block)) return "each content block must be an object";
428
+ const type = typeof block.type === "string" ? block.type : void 0;
429
+ if (!type) return "each content block requires a 'type' field";
430
+ if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
431
+ return `content block type '${type}' is not supported \u2014 this gateway forwards 'text' and 'image_url' only. Send documents as text, or inline them as an 'image_url' data URL if the model reads images`;
432
+ }
433
+ if (type === "text") {
434
+ return typeof block.text === "string" ? void 0 : "a 'text' block requires a string 'text' field";
435
+ }
436
+ return describeImageUrlProblem(block.image_url);
437
+ }
438
+ function describeImageUrlProblem(imageUrl) {
439
+ const url = typeof imageUrl === "string" ? imageUrl : isRecord(imageUrl) ? imageUrl.url : void 0;
440
+ if (typeof url !== "string" || url === "") {
441
+ return "an 'image_url' block requires a non-empty 'image_url.url' string";
442
+ }
443
+ if (url.startsWith("data:")) {
444
+ const match = BASE64_DATA_URL_PATTERN.exec(url);
445
+ if (!match) {
446
+ return `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' with standard base64 (no whitespace or URL-safe characters)`;
447
+ }
448
+ const mimeType = match[1].toLowerCase();
449
+ if (!mimeType.startsWith("image/")) {
450
+ return `data URL MIME type '${mimeType}' is not an image. Only 'image/*' data URLs are forwarded, because providers reject or ignore other types on an 'image_url' block`;
451
+ }
452
+ if (!isDecodableBase64(match[2])) {
453
+ return `base64 payload is not decodable. Check the padding and length \u2014 the data must be a multiple of 4 characters with at most two trailing '='`;
454
+ }
455
+ return void 0;
456
+ }
457
+ let protocol;
458
+ try {
459
+ protocol = new URL(url).protocol;
460
+ } catch {
461
+ return `'${url}' is not a valid URL. Use an http(s) URL or a base64 data URL`;
462
+ }
463
+ if (protocol !== "http:" && protocol !== "https:") {
464
+ return `URL protocol '${protocol}' is not supported. Use an http(s) URL or a base64 data URL`;
465
+ }
466
+ return void 0;
467
+ }
468
+ function normalizeMessageContent(content) {
469
+ if (typeof content === "string") return content;
470
+ if (Array.isArray(content)) {
471
+ return content.map((block) => {
472
+ if (typeof block === "string") return { type: "text", text: block };
473
+ const record = block;
474
+ if ((record == null ? void 0 : record.type) === "image_url" && typeof record.image_url === "string") {
475
+ return { ...record, image_url: { url: record.image_url } };
476
+ }
477
+ return record;
478
+ });
479
+ }
480
+ if (content === null || content === void 0) return "";
481
+ return JSON.stringify(content);
482
+ }
332
483
  const GATEWAY_MANAGED_PARAMETERS = /* @__PURE__ */ new Set(["model", "messages", "tools", "tool_choice", "stream", "n"]);
333
484
  function getProviderRequestParameters(body) {
334
485
  return Object.fromEntries(
@@ -399,6 +550,9 @@ function serializeToolArguments(value) {
399
550
  // Annotate the CommonJS export names for ESM import in node:
400
551
  0 && (module.exports = {
401
552
  applyProviderRequestParameters,
553
+ findContentBlockProblem,
554
+ findMessageProblem,
402
555
  getProviderRequestParameters,
403
- handleChatCompletions
556
+ handleChatCompletions,
557
+ normalizeMessageContent
404
558
  });