plugin-ai-api 1.0.24 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/757.6568d3504ad29352.js +10 -0
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/757.f2bc9cfba07004b0.js +10 -0
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/externalVersion.js +8 -8
  17. package/dist/locale/en-US.json +27 -8
  18. package/dist/locale/vi-VN.json +27 -8
  19. package/dist/locale/zh-CN.json +27 -8
  20. package/dist/server/billing.js +31 -33
  21. package/dist/server/collections/ai-api-config.js +7 -7
  22. package/dist/server/collections/ai-api-group-members.js +62 -0
  23. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  24. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  25. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  26. package/dist/server/collections/ai-api-usage-records.js +2 -0
  27. package/dist/server/middleware/rate-limit.js +7 -6
  28. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  29. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  30. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  31. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  32. package/dist/server/plugin.js +100 -22
  33. package/dist/server/quota-groups.js +108 -0
  34. package/dist/server/resource/ai-api-config.js +5 -3
  35. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  36. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  37. package/dist/server/routes/agent-completions.js +2 -1
  38. package/dist/server/routes/chat-completions.js +121 -42
  39. package/dist/server/routes/completions.js +48 -29
  40. package/dist/server/routes/embeddings.js +2 -1
  41. package/dist/server/routes/models.js +2 -1
  42. package/dist/server/routes/router.js +3 -2
  43. package/dist/server/services/file-processor.js +426 -0
  44. package/dist/server/usage.js +37 -3
  45. package/dist/server/utils/direct-llm-context.js +163 -26
  46. package/dist/server/utils/openai-format.js +21 -2
  47. package/dist/server/utils/rate-limiter.js +1 -1
  48. package/dist/server/utils/request-cache.js +61 -0
  49. package/dist/server/utils/resolve-service.js +2 -1
  50. package/dist/server/utils/user-permissions.js +25 -39
  51. package/dist/server/validation.js +7 -0
  52. package/dist/swagger.js +48 -10
  53. package/package.json +1 -1
  54. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  55. package/src/client/plugin.tsx +5 -16
  56. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  57. package/src/client-v2/locale.ts +3 -1
  58. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  59. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  60. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  61. package/src/client-v2/pages/UsagePage.tsx +9 -0
  62. package/src/client-v2/plugin.tsx +4 -13
  63. package/src/constants.ts +0 -7
  64. package/src/locale/en-US.json +27 -8
  65. package/src/locale/vi-VN.json +27 -8
  66. package/src/locale/zh-CN.json +27 -8
  67. package/src/server/__tests__/billing-quota.test.ts +28 -9
  68. package/src/server/__tests__/direct-llm-context.test.ts +209 -10
  69. package/src/server/__tests__/file-processor.test.ts +225 -0
  70. package/src/server/__tests__/models.test.ts +1 -1
  71. package/src/server/__tests__/openai-format.test.ts +12 -2
  72. package/src/server/__tests__/permission-sync.test.ts +34 -35
  73. package/src/server/__tests__/request-body.test.ts +45 -2
  74. package/src/server/__tests__/usage-groups.test.ts +160 -0
  75. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  76. package/src/server/__tests__/usage-route.test.ts +382 -5
  77. package/src/server/__tests__/usage.test.ts +57 -0
  78. package/src/server/__tests__/user-permissions.test.ts +214 -133
  79. package/src/server/__tests__/validation.test.ts +11 -0
  80. package/src/server/billing.ts +36 -39
  81. package/src/server/collections/ai-api-config.ts +9 -7
  82. package/src/server/collections/ai-api-group-members.ts +41 -0
  83. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  84. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  85. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  86. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  87. package/src/server/collections/ai-api-usage-records.ts +2 -0
  88. package/src/server/index.ts +10 -10
  89. package/src/server/middleware/rate-limit.ts +68 -70
  90. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  91. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  92. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  93. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  94. package/src/server/plugin.ts +121 -30
  95. package/src/server/quota-groups.ts +117 -0
  96. package/src/server/resource/ai-api-config.ts +5 -3
  97. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  98. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  99. package/src/server/routes/agent-completions.ts +2 -1
  100. package/src/server/routes/chat-completions.ts +173 -47
  101. package/src/server/routes/completions.ts +50 -27
  102. package/src/server/routes/embeddings.ts +2 -1
  103. package/src/server/routes/models.ts +4 -3
  104. package/src/server/routes/router.ts +4 -3
  105. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  106. package/src/server/services/file-processor.ts +513 -0
  107. package/src/server/usage.ts +51 -1
  108. package/src/server/utils/direct-llm-context.ts +218 -31
  109. package/src/server/utils/openai-format.ts +25 -2
  110. package/src/server/utils/rate-limiter.ts +83 -83
  111. package/src/server/utils/request-cache.ts +59 -0
  112. package/src/server/utils/resolve-service.ts +83 -82
  113. package/src/server/utils/user-permissions.ts +49 -69
  114. package/src/server/validation.ts +7 -0
  115. package/src/swagger.ts +52 -11
  116. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  117. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  118. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  119. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  120. package/dist/client/902.e74518750f1e4201.js +0 -10
  121. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  122. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  123. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  124. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  125. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  126. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  127. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  128. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  129. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  130. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -0,0 +1,59 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import type { Context } from '@nocobase/actions';
11
+ import type { Model } from '@nocobase/database';
12
+ import { resolveUserGroup, type AiApiUsageGroup } from '../quota-groups';
13
+
14
+ interface AiApiRequestCache {
15
+ configLoaded: boolean;
16
+ config: Model | null;
17
+ groupKey?: string;
18
+ group?: AiApiUsageGroup;
19
+ }
20
+
21
+ function getCache(ctx: Context): AiApiRequestCache {
22
+ if (!ctx.state.aiApiRequestCache) {
23
+ ctx.state.aiApiRequestCache = { configLoaded: false, config: null };
24
+ }
25
+ return ctx.state.aiApiRequestCache;
26
+ }
27
+
28
+ /**
29
+ * Returns the aiApiConfig row, reading it at most once per request. The same row
30
+ * is consulted by the body limit, mode resolution, model whitelist, quota and
31
+ * billing steps of a single gateway request, so caching it on ctx.state removes
32
+ * several duplicate queries per request.
33
+ */
34
+ export async function getAiApiConfig(ctx: Context): Promise<Model | null> {
35
+ const cache = getCache(ctx);
36
+ if (!cache.configLoaded) {
37
+ cache.config = (await ctx.db.getRepository('aiApiConfig').findOne()) ?? null;
38
+ cache.configLoaded = true;
39
+ }
40
+ return cache.config;
41
+ }
42
+
43
+ /**
44
+ * Returns the caller's usage group, resolving it at most once per request per
45
+ * user id. Wraps resolveUserGroup, which otherwise runs membership queries on
46
+ * every call site (rate limiting, permissions, billing, context overflow).
47
+ */
48
+ export async function resolveRequestUserGroup(
49
+ ctx: Context,
50
+ userId: string | number | bigint | undefined | null,
51
+ ): Promise<AiApiUsageGroup> {
52
+ const cache = getCache(ctx);
53
+ const groupKey = userId === undefined || userId === null ? '' : String(userId);
54
+ if (!cache.group || cache.groupKey !== groupKey) {
55
+ cache.group = await resolveUserGroup(ctx, userId);
56
+ cache.groupKey = groupKey;
57
+ }
58
+ return cache.group;
59
+ }
@@ -1,82 +1,83 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import { Context } from '@nocobase/actions';
11
-
12
- /**
13
- * Resolve an LLM service by name or title.
14
- */
15
- export async function resolveLlmService(ctx: Context, serviceKey: string) {
16
- const repo = ctx.db.getRepository('llmServices');
17
-
18
- let service = await repo.findOne({ filter: { name: serviceKey } });
19
- if (!service) {
20
- service = await repo.findOne({ filter: { title: serviceKey } });
21
- }
22
- return service;
23
- }
24
-
25
- /**
26
- * Resolve a model string to a service + modelId.
27
- *
28
- * Strategy (priority order):
29
- * 1. Try splitting at each "/" position and match the left part against DB (name or title).
30
- * This handles cases like "Custom LLM (OpenAI Compatible)/qwen/qwen3.6-plus-preview:free"
31
- * 2. If no service match, use the defaultLlmService from config and treat the ENTIRE
32
- * model string as the modelId. This allows clients to send just "qwen/qwen3.6-plus-preview:free"
33
- * or "gpt-4o" without knowing the service name.
34
- */
35
- export async function resolveModelString(
36
- ctx: Context,
37
- modelString: string,
38
- ): Promise<{ service: any; modelId: string } | null> {
39
- const repo = ctx.db.getRepository('llmServices');
40
-
41
- // ─── Strategy 1: Try splitting at "/" positions ───
42
- const slashPositions: number[] = [];
43
- for (let i = 0; i < modelString.length; i++) {
44
- if (modelString[i] === '/') {
45
- slashPositions.push(i);
46
- }
47
- }
48
-
49
- if (slashPositions.length > 0) {
50
- for (const pos of slashPositions) {
51
- const serviceKey = modelString.substring(0, pos);
52
- const modelId = modelString.substring(pos + 1);
53
- if (!serviceKey || !modelId) continue;
54
-
55
- let service = await repo.findOne({ filter: { name: serviceKey } });
56
- if (!service) {
57
- service = await repo.findOne({ filter: { title: serviceKey } });
58
- }
59
-
60
- if (service) {
61
- return { service, modelId };
62
- }
63
- }
64
- }
65
-
66
- // ─── Strategy 2: Use default LLM service from config ───
67
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
68
- if (config?.defaultLlmService) {
69
- const service = await repo.findOne({ filter: { name: config.defaultLlmService } });
70
- if (service) {
71
- return { service, modelId: modelString };
72
- }
73
- }
74
-
75
- // ─── Strategy 3: If only one service enabled, use it ───
76
- const enabledServices = await repo.find({ filter: { enabled: true } });
77
- if (enabledServices.length === 1) {
78
- return { service: enabledServices[0], modelId: modelString };
79
- }
80
-
81
- return null;
82
- }
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { Context } from '@nocobase/actions';
11
+ import { getAiApiConfig } from './request-cache';
12
+
13
+ /**
14
+ * Resolve an LLM service by name or title.
15
+ */
16
+ export async function resolveLlmService(ctx: Context, serviceKey: string) {
17
+ const repo = ctx.db.getRepository('llmServices');
18
+
19
+ let service = await repo.findOne({ filter: { name: serviceKey } });
20
+ if (!service) {
21
+ service = await repo.findOne({ filter: { title: serviceKey } });
22
+ }
23
+ return service;
24
+ }
25
+
26
+ /**
27
+ * Resolve a model string to a service + modelId.
28
+ *
29
+ * Strategy (priority order):
30
+ * 1. Try splitting at each "/" position and match the left part against DB (name or title).
31
+ * This handles cases like "Custom LLM (OpenAI Compatible)/qwen/qwen3.6-plus-preview:free"
32
+ * 2. If no service match, use the defaultLlmService from config and treat the ENTIRE
33
+ * model string as the modelId. This allows clients to send just "qwen/qwen3.6-plus-preview:free"
34
+ * or "gpt-4o" without knowing the service name.
35
+ */
36
+ export async function resolveModelString(
37
+ ctx: Context,
38
+ modelString: string,
39
+ ): Promise<{ service: any; modelId: string } | null> {
40
+ const repo = ctx.db.getRepository('llmServices');
41
+
42
+ // ─── Strategy 1: Try splitting at "/" positions ───
43
+ const slashPositions: number[] = [];
44
+ for (let i = 0; i < modelString.length; i++) {
45
+ if (modelString[i] === '/') {
46
+ slashPositions.push(i);
47
+ }
48
+ }
49
+
50
+ if (slashPositions.length > 0) {
51
+ for (const pos of slashPositions) {
52
+ const serviceKey = modelString.substring(0, pos);
53
+ const modelId = modelString.substring(pos + 1);
54
+ if (!serviceKey || !modelId) continue;
55
+
56
+ let service = await repo.findOne({ filter: { name: serviceKey } });
57
+ if (!service) {
58
+ service = await repo.findOne({ filter: { title: serviceKey } });
59
+ }
60
+
61
+ if (service) {
62
+ return { service, modelId };
63
+ }
64
+ }
65
+ }
66
+
67
+ // ─── Strategy 2: Use default LLM service from config ───
68
+ const config = await getAiApiConfig(ctx);
69
+ if (config?.defaultLlmService) {
70
+ const service = await repo.findOne({ filter: { name: config.defaultLlmService } });
71
+ if (service) {
72
+ return { service, modelId: modelString };
73
+ }
74
+ }
75
+
76
+ // ─── Strategy 3: If only one service enabled, use it ───
77
+ const enabledServices = await repo.find({ filter: { enabled: true } });
78
+ if (enabledServices.length === 1) {
79
+ return { service: enabledServices[0], modelId: modelString };
80
+ }
81
+
82
+ return null;
83
+ }
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError } from './openai-format';
12
+ import { resolveRequestUserGroup } from './request-cache';
13
+ import type { AiApiUsageGroup } from '../quota-groups';
12
14
 
13
15
  const SCOPE_TTL_MS = 15_000;
14
16
 
@@ -20,114 +22,94 @@ interface CachedScope {
20
22
  const scopeCache = new Map<string, CachedScope>();
21
23
 
22
24
  /**
23
- * Resolved per-user LLM access, layered *under* the global `aiApiConfig.enabledLlmServices`
24
- * whitelist. A user grant can only ever narrow global access, never widen it.
25
+ * Resolved LLM access of a user's usage group, layered *under* the global
26
+ * `aiApiConfig.enabledLlmServices` whitelist. Group settings can only ever narrow global
27
+ * access, never widen it. Empty lists on the group mean "no narrowing" — the group inherits
28
+ * the full global configuration, so the default group never locks everyone out.
25
29
  */
26
30
  export interface AiApiAccessScope {
27
- /** False when the user has no aiApiUserPermissions row — global config alone decides. */
28
- hasUserRecord: boolean;
29
- /** True when a row exists but is switched off, denying every service. */
30
- denyAll: boolean;
31
- /** null means "no user-level narrowing"; an empty array denies every service. */
32
- allowedServices: string[] | null;
31
+ /** The group whose settings produced this scope. */
32
+ groupId?: string | number | bigint;
33
+ /** Empty means no narrowing; non-empty restricts to these services. */
34
+ allowedServices: string[];
33
35
  allowAllModels: boolean;
34
36
  allowedModels: Set<string>;
35
37
  /**
36
- * True when the lookup itself failed. Distinct from hasUserRecord:false — "this user has no
37
- * restrictions" and "we cannot tell whether this user has restrictions" must not be conflated,
38
- * or a mid-rolling-upgrade missing table silently lifts every user's restrictions.
38
+ * True when the lookup itself failed. Distinct from an open scope — "this user has no
39
+ * restrictions" and "we cannot tell whether this user has restrictions" must not be
40
+ * conflated, or a mid-rolling-upgrade missing table silently lifts every user's restrictions.
39
41
  */
40
42
  lookupFailed: boolean;
41
43
  }
42
44
 
43
- const NO_RECORD_SCOPE: AiApiAccessScope = {
44
- hasUserRecord: false,
45
- denyAll: false,
46
- allowedServices: null,
45
+ const OPEN_SCOPE: AiApiAccessScope = {
46
+ allowedServices: [],
47
47
  allowAllModels: true,
48
48
  allowedModels: new Set(),
49
49
  lookupFailed: false,
50
50
  };
51
51
 
52
- const LOOKUP_FAILED_SCOPE: AiApiAccessScope = { ...NO_RECORD_SCOPE, denyAll: true, lookupFailed: true };
52
+ const LOOKUP_FAILED_SCOPE: AiApiAccessScope = { ...OPEN_SCOPE, lookupFailed: true };
53
53
 
54
54
  /**
55
- * Invalidate cached scopes for one user across every app in this process, or all users when
56
- * called with no argument. Keys are `${appName}:${userId}`, and the afterSave hook only knows
57
- * the user id, so the match is on the suffix.
55
+ * Invalidate cached scopes for one group across every app in this process, or all groups
56
+ * when called with no argument. Keys are `${appName}:group:${groupId}`.
58
57
  *
59
- * This is per-process only: in a multi-node deployment other nodes keep serving their cached
60
- * scope until the 15s TTL expires.
58
+ * Per-process only: in a multi-node deployment other nodes keep serving their cached scope
59
+ * until the 15s TTL expires or the sync message arrives.
61
60
  */
62
- export function invalidateUserPermissionCache(userId?: string | number | bigint): void {
63
- if (userId === undefined || userId === null) {
61
+ export function invalidateGroupAccessCache(groupId?: string | number | bigint): void {
62
+ if (groupId === undefined || groupId === null) {
64
63
  scopeCache.clear();
65
64
  return;
66
65
  }
67
- const suffix = `:${userId}`;
66
+ const suffix = `:group:${groupId}`;
68
67
  for (const key of scopeCache.keys()) {
69
68
  if (key.endsWith(suffix)) scopeCache.delete(key);
70
69
  }
71
70
  }
72
71
 
73
- /**
74
- * Sequelize instances expose columns through .get() only — a plain property read returns
75
- * undefined for most fields. Mirrors valueOf() in billing.ts so plain-object test fixtures
76
- * work too.
77
- */
78
- function valueOf<T>(row: unknown, name: string): T {
79
- if (!row) return undefined as T;
80
- const candidate = row as { get?: (key: string) => unknown };
81
- if (typeof candidate.get === 'function') return candidate.get(name) as T;
82
- return (row as Record<string, unknown>)[name] as T;
83
- }
84
-
85
72
  function toStringArray(value: unknown): string[] {
86
73
  if (!Array.isArray(value)) return [];
87
74
  return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
88
75
  }
89
76
 
90
- /** Build a scope from an aiApiUserPermissions row (or absence of one). */
91
- export function buildAccessScope(row: unknown): AiApiAccessScope {
92
- if (!row) return NO_RECORD_SCOPE;
93
- if (valueOf<boolean>(row, 'enabled') === false) {
94
- return { ...NO_RECORD_SCOPE, hasUserRecord: true, denyAll: true, allowedServices: [] };
95
- }
77
+ /** Build a scope from a usage group. Empty lists mean no narrowing. */
78
+ export function buildAccessScope(group: AiApiUsageGroup): AiApiAccessScope {
96
79
  return {
97
- hasUserRecord: true,
98
- denyAll: false,
99
- allowedServices: toStringArray(valueOf(row, 'allowedLlmServices')),
100
- allowAllModels: valueOf<boolean>(row, 'allowAllModels') !== false,
101
- allowedModels: new Set(toStringArray(valueOf(row, 'allowedModels'))),
80
+ groupId: group.id,
81
+ allowedServices: toStringArray(group.allowedLlmServices),
82
+ allowAllModels: group.allowAllModels !== false,
83
+ allowedModels: new Set(toStringArray(group.allowedModels)),
102
84
  lookupFailed: false,
103
85
  };
104
86
  }
105
87
 
106
88
  /**
107
- * Load the current user's access scope, cached for 15s per user id — the same TTL the role
108
- * permission cache uses. Invalidated by afterSave/afterDestroy hooks in plugin.ts.
89
+ * Load the access scope of the current user's usage group, cached for 15s per group id —
90
+ * the same TTL the role permission cache uses. Membership is resolved live on every request
91
+ * and memoized for its duration (one indexed query even though several call sites ask), so
92
+ * moving a user between groups needs no invalidation; group edits are invalidated by the
93
+ * afterSave/afterDestroy hooks in plugin.ts.
109
94
  */
110
95
  export async function resolveUserAccessScope(ctx: Context): Promise<AiApiAccessScope> {
111
96
  const userId = ctx.state.currentUser?.id;
112
- if (userId === undefined || userId === null) return NO_RECORD_SCOPE;
113
97
 
114
- // Sub-apps share this process but have separate databases, so user id 1 in one app is a
115
- // different person than user id 1 in another. Without the app prefix they collide here.
116
- const key = `${ctx.app?.name ?? 'main'}:${userId}`;
117
- const cached = scopeCache.get(key);
118
- if (cached && cached.expiresAt > Date.now()) return cached.scope;
119
-
120
- let scope: AiApiAccessScope;
98
+ let group: AiApiUsageGroup;
121
99
  try {
122
- const row = await ctx.db.getRepository('aiApiUserPermissions').findOne({ filter: { userId } });
123
- scope = buildAccessScope(row);
100
+ group = await resolveRequestUserGroup(ctx, userId);
124
101
  } catch (err) {
125
102
  // Fail closed. A failed lookup cannot be treated as "no restrictions": during a rolling
126
- // upgrade the table may not exist yet, and that must not lift every user's restrictions.
127
- ctx.log?.error?.('AI API user permissions lookup failed, denying access:', err);
103
+ // upgrade the tables may not exist yet, and that must not lift every user's restrictions.
104
+ ctx.log?.error?.('AI API group access lookup failed, denying access:', err);
128
105
  return LOOKUP_FAILED_SCOPE;
129
106
  }
130
107
 
108
+ const key = `${ctx.app?.name ?? 'main'}:group:${group.id}`;
109
+ const cached = scopeCache.get(key);
110
+ if (cached && cached.expiresAt > Date.now()) return cached.scope;
111
+
112
+ const scope = buildAccessScope(group);
131
113
  scopeCache.set(key, { scope, expiresAt: Date.now() + SCOPE_TTL_MS });
132
114
  return scope;
133
115
  }
@@ -137,28 +119,26 @@ function matchesService(list: string[], serviceName?: string, serviceTitle?: str
137
119
  }
138
120
 
139
121
  /**
140
- * Effective service check: the global whitelist AND the user grant must both allow it.
122
+ * Effective service check: the global whitelist AND the group settings must both allow it.
141
123
  *
142
124
  * An empty global whitelist means "expose all services", preserving existing behaviour.
143
- * An empty user grant means "deny everything" — the record itself is the opt-in.
125
+ * An empty group list means "no narrowing" — the group inherits the global configuration.
144
126
  */
145
127
  export function isServiceAllowed(
146
128
  scope: AiApiAccessScope,
147
129
  globalEnabledServices: unknown,
148
130
  service: { name?: string; title?: string },
149
131
  ): boolean {
150
- // denyAll is checked before hasUserRecord: a failed lookup denies without having a record.
151
- if (scope.denyAll) return false;
132
+ if (scope.lookupFailed) return false;
152
133
  const globalList = toStringArray(globalEnabledServices);
153
134
  if (globalList.length && !matchesService(globalList, service.name, service.title)) return false;
154
- if (!scope.hasUserRecord) return true;
155
- return matchesService(scope.allowedServices ?? [], service.name, service.title);
135
+ if (!scope.allowedServices.length) return true;
136
+ return matchesService(scope.allowedServices, service.name, service.title);
156
137
  }
157
138
 
158
139
  /** Model-level narrowing on top of isServiceAllowed, keyed by "serviceName/modelId". */
159
140
  export function isModelAllowed(scope: AiApiAccessScope, fullModelId: string): boolean {
160
- if (scope.denyAll) return false;
161
- if (!scope.hasUserRecord) return true;
141
+ if (scope.lookupFailed) return false;
162
142
  if (scope.allowAllModels) return true;
163
143
  return scope.allowedModels.has(fullModelId);
164
144
  }
@@ -53,6 +53,10 @@ export function validateModelMetadata(model: Model): void {
53
53
  if (!String(model.get('model') ?? '').trim()) throw new Error('model is required.');
54
54
  requirePositiveIntegerOrNull(model.get('contextWindow'), 'contextWindow');
55
55
  requirePositiveIntegerOrNull(model.get('maxCompletionTokens'), 'maxCompletionTokens');
56
+ const systemPrompt = model.get('systemPrompt');
57
+ if (systemPrompt !== null && systemPrompt !== undefined && typeof systemPrompt !== 'string') {
58
+ throw new Error('systemPrompt must be a string.');
59
+ }
56
60
 
57
61
  const contextWindow = model.get('contextWindow');
58
62
  const maxCompletionTokens = model.get('maxCompletionTokens');
@@ -73,6 +77,9 @@ export function validateQuotaPolicy(model: Model): void {
73
77
  if (!['daily', 'monthly'].includes(String(model.get('periodType')))) {
74
78
  throw new Error('periodType must be daily or monthly.');
75
79
  }
80
+ if (!['share', 'per_user'].includes(String(model.get('quotaMode')))) {
81
+ throw new Error('quotaMode must be share or per_user.');
82
+ }
76
83
  if (!['allow', 'use_reserved'].includes(String(model.get('missingUsageBehavior')))) {
77
84
  throw new Error('missingUsageBehavior must be allow or use_reserved.');
78
85
  }
package/src/swagger.ts CHANGED
@@ -59,8 +59,8 @@ export default {
59
59
  description:
60
60
  'Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\n' +
61
61
  "The catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's " +
62
- '`aiApiUserPermissions` record when one exists. A user grant can only narrow the global whitelist, never widen it, so two ' +
63
- 'users may receive different lists from the same request.',
62
+ 'usage group settings (`allowedLlmServices` / `allowedModels`). Group settings can only narrow the global whitelist, never ' +
63
+ 'widen it, so two users may receive different lists from the same request.',
64
64
  security: [{ BearerAuth: [] }],
65
65
  responses: {
66
66
  200: {
@@ -252,17 +252,16 @@ export default {
252
252
  enum: ['llm', 'agent'],
253
253
  description: 'Default AI mode',
254
254
  },
255
- defaultAiEmployee: { type: 'string', description: 'Default AI employee name' },
255
+ defaultAiEmployee: {
256
+ type: 'string',
257
+ description: 'Default AI employee name (agent mode only; direct LLM mode ignores it)',
258
+ },
256
259
  defaultLlmService: { type: 'string', description: 'Default LLM service name' },
257
260
  enabledLlmServices: {
258
261
  type: 'array',
259
262
  items: { type: 'string' },
260
263
  description:
261
- 'List of enabled LLM service names. This is the outer bound for every caller; per-user `aiApiUserPermissions` records can only narrow it further.',
262
- },
263
- rateLimitPerMinute: {
264
- type: 'integer',
265
- description: 'Max requests per minute per user (0 = unlimited)',
264
+ 'List of enabled LLM service names. This is the outer bound for every caller; usage group settings can only narrow it further.',
266
265
  },
267
266
  maxRequestBodyMb: {
268
267
  type: 'integer',
@@ -273,6 +272,13 @@ export default {
273
272
  'Max request body size in megabytes. Requests above this return 413. ' +
274
273
  'The gateway buffers each body in memory, so values above 100 are rejected.',
275
274
  },
275
+ pdfRenderPagesAsImages: {
276
+ type: 'boolean',
277
+ default: false,
278
+ description:
279
+ 'When true, PDF file/file_url blocks are rendered to per-page PNG images and sent as image_url blocks. ' +
280
+ 'Requires a registered PdfToImageRenderer. When false or no renderer is available, PDFs are forwarded as file blocks.',
281
+ },
276
282
  },
277
283
  },
278
284
  ModelObject: {
@@ -287,10 +293,10 @@ export default {
287
293
  ContentBlock: {
288
294
  type: 'object',
289
295
  description:
290
- 'A multimodal content block. Only text and image_url blocks are forwarded to the provider; ' +
291
- 'any other type is rejected with 400 unsupported_content_block.',
296
+ 'A multimodal content block. text, image_url, file and file_url blocks are forwarded; ' +
297
+ 'file and file_url blocks are first run through the configurable file processor service.',
292
298
  properties: {
293
- type: { type: 'string', enum: ['text', 'image_url'] },
299
+ type: { type: 'string', enum: ['text', 'image_url', 'file', 'file_url'] },
294
300
  text: { type: 'string' },
295
301
  image_url: {
296
302
  type: 'object',
@@ -304,6 +310,35 @@ export default {
304
310
  },
305
311
  required: ['url'],
306
312
  },
313
+ file: {
314
+ type: 'object',
315
+ properties: {
316
+ file_data: {
317
+ type: 'string',
318
+ description: 'A base64 data URL, e.g. data:application/pdf;base64,JVBERi0...',
319
+ example: 'data:application/pdf;base64,JVBERi0...',
320
+ },
321
+ filename: { type: 'string' },
322
+ mime_type: {
323
+ type: 'string',
324
+ description: 'MIME type of the file, e.g. application/pdf',
325
+ example: 'application/pdf',
326
+ },
327
+ },
328
+ required: ['file_data'],
329
+ },
330
+ file_url: {
331
+ type: 'object',
332
+ properties: {
333
+ url: {
334
+ type: 'string',
335
+ description:
336
+ 'An http(s) URL pointing to a file. The gateway downloads the file and converts it to a file block.',
337
+ example: 'https://example.com/document.pdf',
338
+ },
339
+ },
340
+ required: ['url'],
341
+ },
307
342
  },
308
343
  required: ['type'],
309
344
  },
@@ -366,6 +401,12 @@ export default {
366
401
  prompt_tokens: { type: 'integer' },
367
402
  completion_tokens: { type: 'integer' },
368
403
  total_tokens: { type: 'integer' },
404
+ prompt_tokens_details: {
405
+ type: 'object',
406
+ properties: {
407
+ cached_tokens: { type: 'integer' },
408
+ },
409
+ },
369
410
  },
370
411
  },
371
412
  },
@@ -1,10 +0,0 @@
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
- "use strict";(self.webpackChunkplugin_ai_api=self.webpackChunkplugin_ai_api||[]).push([["123"],{46:function(e,t,r){r.r(t),r.d(t,{default:function(){return p}});var n=r(155),l=r.n(n),a=r(59),o=r(694),i=r(650),u=r(630);function s(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r<t;r++)n[r]=e[r];return n}function c(e,t,r,n,l,a,o){try{var i=e[a](o),u=i.value}catch(e){r(e);return}i.done?t(u):Promise.resolve(u).then(n,l)}function d(e){return function(){var t=this,r=arguments;return new Promise(function(n,l){var a=e.apply(t,r);function o(e){c(a,n,l,o,i,"next",e)}function i(e){c(a,n,l,o,i,"throw",e)}o(void 0)})}}function m(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var r,n,l=null==e?null:"u">typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=l){var a=[],o=!0,i=!1;try{for(l=l.call(e);!(o=(r=l.next()).done)&&(a.push(r.value),!t||a.length!==t);o=!0);}catch(e){i=!0,n=e}finally{try{o||null==l.return||l.return()}finally{if(i)throw n}}return a}}(e,t)||function(e,t){if(e){if("string"==typeof e)return s(e,t);var r=Object.prototype.toString.call(e).slice(8,-1);if("Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r)return Array.from(r);if("Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return s(e,t)}}(e,t)||function(){throw TypeError("Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function f(e,t){var r,n,l,a={label:0,sent:function(){if(1&l[0])throw l[1];return l[1]},trys:[],ops:[]},o=Object.create(("function"==typeof Iterator?Iterator:Object).prototype),i=Object.defineProperty;return i(o,"next",{value:u(0)}),i(o,"throw",{value:u(1)}),i(o,"return",{value:u(2)}),"function"==typeof Symbol&&i(o,Symbol.iterator,{value:function(){return this}}),o;function u(i){return function(u){var s=[i,u];if(r)throw TypeError("Generator is already executing.");for(;o&&(o=0,s[0]&&(a=0)),a;)try{if(r=1,n&&(l=2&s[0]?n.return:s[0]?n.throw||((l=n.return)&&l.call(n),0):n.next)&&!(l=l.call(n,s[1])).done)return l;switch(n=0,l&&(s=[2&s[0],l.value]),s[0]){case 0:case 1:l=s;break;case 4:return a.label++,{value:s[1],done:!1};case 5:a.label++,n=s[1],s=[0];continue;case 7:s=a.ops.pop(),a.trys.pop();continue;default:if(!(l=(l=a.trys).length>0&&l[l.length-1])&&(6===s[0]||2===s[0])){a=0;continue}if(3===s[0]&&(!l||s[1]>l[0]&&s[1]<l[3])){a.label=s[1];break}if(6===s[0]&&a.label<l[1]){a.label=l[1],l=s;break}if(l&&a.label<l[2]){a.label=l[2],a.ops.push(s);break}l[2]&&a.ops.pop(),a.trys.pop();continue}s=t.call(e,a)}catch(e){s=[6,e],n=0}finally{r=l=0}if(5&s[0])throw s[1];return{value:s[0]?s[1]:void 0,done:!0}}}}function p(){var e=(0,o.useFlowContext)(),t=(0,i.k)(),r=m(a.Form.useForm(),1)[0],s=a.Form.useWatch("allowedLlmServices",r),c=a.Form.useWatch("allowAllModels",r),p=m((0,n.useState)([]),2),v=p[0],h=p[1],b=m((0,n.useState)(0),2),y=b[0],w=b[1],S=m((0,n.useState)(1),2),g=S[0],E=S[1],k=m((0,n.useState)([]),2),A=k[0],M=k[1],I=m((0,n.useState)(""),2),F=I[0],C=I[1],U=m((0,n.useState)([]),2),x=U[0],L=U[1],P=m((0,n.useState)(!1),2),T=P[0],O=P[1],j=m((0,n.useState)(!1),2),z=j[0],q=j[1],B=m((0,n.useState)(),2),D=B[0],N=B[1],_=m((0,n.useState)(!1),2),G=_[0],V=_[1],W=(0,n.useCallback)(function(){return d(function(){var t,r,n,l,o,i,s;return f(this,function(c){switch(c.label){case 0:O(!0),c.label=1;case 1:return c.trys.push([1,3,4,5]),[4,Promise.all([e.api.request({url:"aiApiUserPermissions:list",method:"get",params:{page:g,pageSize:20,appends:["user"],sort:"-updatedAt"}}),e.api.request({url:"ai:listAllEnabledModels",method:"get"})])];case 2:return o=(l=m.apply(void 0,[c.sent(),2]))[0],i=l[1],h((0,u.m)(o,[])),w(null!=(t=null==o||null==(n=o.data)||null==(r=n.meta)?void 0:r.count)?t:0),L((0,u.m)(i,[])),[3,5];case 3:return s=c.sent(),a.message.error((0,u.g)(s)),[3,5];case 4:return O(!1),[7];case 5:return[2]}})})()},[e.api,g]);(0,n.useEffect)(function(){W()},[W]);var J=(0,n.useCallback)(function(t){return d(function(){var r,n;return f(this,function(l){switch(l.label){case 0:return l.trys.push([0,2,,3]),[4,e.api.request({url:"aiApiUserPermissions:listUsers",method:"get",params:{keyword:t,pageSize:50,excludeGranted:!D}})];case 1:return r=l.sent(),M((0,u.m)(r,[])),[3,3];case 2:return n=l.sent(),a.message.error((0,u.g)(n)),[3,3];case 3:return[2]}})})()},[e.api,D]);(0,n.useEffect)(function(){if(G){var e=setTimeout(function(){return J(F)},300);return function(){return clearTimeout(e)}}},[G,F,J]);var K=(0,n.useMemo)(function(){return x.map(function(e){return{label:e.llmServiceTitle||e.llmService,value:e.llmService}})},[x]),$=(0,n.useMemo)(function(){var e=new Set(s||[]);return x.filter(function(t){return e.has(t.llmService)}).flatMap(function(e){return(e.enabledModels||[]).map(function(t){return{label:"".concat(e.llmServiceTitle||e.llmService," / ").concat(t.label||t.value),value:"".concat(e.llmService,"/").concat(t.value)}})})},[x,s]),H=function(e){N(e),C(""),M(e.user?[e.user]:[]),r.setFieldsValue({userId:e.userId,enabled:e.enabled,allowAllModels:e.allowAllModels,allowedLlmServices:e.allowedLlmServices||[],allowedModels:e.allowedModels||[]}),V(!0)},Q=function(e){var t;return(null==e?void 0:e.nickname)||(null==e?void 0:e.username)||(null==e?void 0:e.email)||String(null!=(t=null==e?void 0:e.id)?t:"")},R=function(e){var t;return(null==(t=K.find(function(t){return t.value===e}))?void 0:t.label)||e},X=[{title:t("User"),key:"user",width:180,render:function(e,t){return Q(t.user)||String(t.userId)}},{title:t("Allowed LLM services"),dataIndex:"allowedLlmServices",key:"allowedLlmServices",render:function(e){return(null==e?void 0:e.length)?l().createElement(a.Space,{size:[0,4],wrap:!0},e.map(function(e){return l().createElement(a.Tag,{key:e},R(e))})):l().createElement(a.Tag,{color:"red"},t("No service allowed"))}},{title:t("Allowed models"),key:"allowedModels",width:220,render:function(e,r){return r.allowAllModels?l().createElement(a.Tag,{color:"blue"},t("All models of allowed services")):l().createElement(a.Space,{size:[0,4],wrap:!0},(r.allowedModels||[]).map(function(e){return l().createElement(a.Tag,{key:e},e)}))}},{title:t("Status"),dataIndex:"enabled",key:"enabled",width:100,render:function(e){return l().createElement(a.Tag,{color:e?"green":"default"},e?t("Enabled"):t("Disabled"))}},{title:t("Actions"),key:"actions",width:150,fixed:"right",render:function(r,n){return l().createElement(a.Space,{size:0},l().createElement(a.Button,{type:"link",onClick:function(){return H(n)}},t("Edit")),l().createElement(a.Popconfirm,{title:t("Delete this permission?"),onConfirm:function(){return d(function(){var r;return f(this,function(l){switch(l.label){case 0:return l.trys.push([0,3,,4]),[4,e.api.request({url:"aiApiUserPermissions:destroy/".concat(n.id),method:"post"})];case 1:return l.sent(),a.message.success(t("Deleted successfully")),[4,W()];case 2:return l.sent(),[3,4];case 3:return r=l.sent(),a.message.error((0,u.g)(r)),[3,4];case 4:return[2]}})})()}},l().createElement(a.Button,{type:"link",danger:!0},t("Delete"))))}}];return l().createElement(a.Card,{title:t("User LLM permissions"),extra:l().createElement(a.Button,{type:"primary",onClick:function(){N(void 0),C(""),r.setFieldsValue({enabled:!0,allowedLlmServices:[],allowAllModels:!0,allowedModels:[]}),V(!0)}},t("Add permission"))},l().createElement(a.Alert,{type:"info",showIcon:!0,style:{marginBottom:16},message:t("Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.")}),l().createElement(a.Table,{rowKey:"id",columns:X,dataSource:v,loading:T,scroll:{x:1e3},pagination:{current:g,pageSize:20,total:y,showSizeChanger:!1,onChange:E}}),l().createElement(a.Modal,{title:D?t("Edit permission"):t("Add permission"),open:G,onCancel:function(){return V(!1)},onOk:function(){return d(function(){var n,l;return f(this,function(o){switch(o.label){case 0:return[4,r.validateFields()];case 1:n=o.sent(),q(!0),o.label=2;case 2:return o.trys.push([2,5,6,7]),[4,e.api.request({url:D?"aiApiUserPermissions:update/".concat(D.id):"aiApiUserPermissions:create",method:"post",data:n})];case 3:return o.sent(),a.message.success(t("Saved successfully")),V(!1),[4,W()];case 4:return o.sent(),[3,7];case 5:return l=o.sent(),a.message.error((0,u.g)(l)),[3,7];case 6:return q(!1),[7];case 7:return[2]}})})()},confirmLoading:z,destroyOnClose:!0},l().createElement(a.Form,{form:r,layout:"vertical",preserve:!1},l().createElement(a.Form.Item,{name:"userId",label:t("User"),rules:[{required:!0}]},l().createElement(a.Select,{disabled:!!D,showSearch:!0,filterOption:!1,onSearch:C,notFoundContent:null,options:A.map(function(e){return{label:Q(e),value:e.id}})})),l().createElement(a.Form.Item,{name:"allowedLlmServices",label:t("Allowed LLM services"),extra:t("Only services also enabled in the general configuration take effect.")},l().createElement(a.Select,{mode:"multiple",allowClear:!0,showSearch:!0,optionFilterProp:"label",options:K})),l().createElement(a.Form.Item,{name:"allowAllModels",label:t("Allow all models"),valuePropName:"checked"},l().createElement(a.Switch,null)),!1===c&&l().createElement(a.Form.Item,{name:"allowedModels",label:t("Allowed models")},l().createElement(a.Select,{mode:"multiple",allowClear:!0,showSearch:!0,optionFilterProp:"label",options:$})),l().createElement(a.Form.Item,{name:"enabled",label:t("Enabled"),valuePropName:"checked"},l().createElement(a.Switch,null)))))}},650:function(e,t,r){r.d(t,{k:function(){return a}});var n=r(694),l=JSON.parse('{"UU":"plugin-ai-api"}');function a(){var e=(0,n.useFlowEngine)();return function(t){return e.context.t(t,{ns:[l.UU,"client"]})}}},630:function(e,t,r){function n(e,t){var r,n;return e&&(void 0===e?"undefined":e&&"u">typeof Symbol&&e.constructor===Symbol?"symbol":typeof e)=="object"?null!=(r=null==(n=e.data)?void 0:n.data)?r:t:t}function l(e){var t;return(null!=(t=Error)&&"u">typeof Symbol&&t[Symbol.hasInstance]?!!t[Symbol.hasInstance](e):e instanceof t)?e.message:String(e)}r.d(t,{g:function(){return l},m:function(){return n}})}}]);
@@ -1,10 +0,0 @@
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
- "use strict";(self.webpackChunkplugin_ai_api=self.webpackChunkplugin_ai_api||[]).push([["302"],{581:function(e,t,r){r.r(t),r.d(t,{default:function(){return d}});var n=r(155),a=r.n(n),l=r(59),o=r(694),i=r(650),u=r(630);function c(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r<t;r++)n[r]=e[r];return n}function s(e,t,r,n,a,l,o){try{var i=e[l](o),u=i.value}catch(e){r(e);return}i.done?t(u):Promise.resolve(u).then(n,a)}function m(e){return function(){var t=this,r=arguments;return new Promise(function(n,a){var l=e.apply(t,r);function o(e){s(l,n,a,o,i,"next",e)}function i(e){s(l,n,a,o,i,"throw",e)}o(void 0)})}}function p(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var r,n,a=null==e?null:"u">typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=a){var l=[],o=!0,i=!1;try{for(a=a.call(e);!(o=(r=a.next()).done)&&(l.push(r.value),!t||l.length!==t);o=!0);}catch(e){i=!0,n=e}finally{try{o||null==a.return||a.return()}finally{if(i)throw n}}return l}}(e,t)||function(e,t){if(e){if("string"==typeof e)return c(e,t);var r=Object.prototype.toString.call(e).slice(8,-1);if("Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r)return Array.from(r);if("Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return c(e,t)}}(e,t)||function(){throw TypeError("Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function f(e,t){var r,n,a,l={label:0,sent:function(){if(1&a[0])throw a[1];return a[1]},trys:[],ops:[]},o=Object.create(("function"==typeof Iterator?Iterator:Object).prototype),i=Object.defineProperty;return i(o,"next",{value:u(0)}),i(o,"throw",{value:u(1)}),i(o,"return",{value:u(2)}),"function"==typeof Symbol&&i(o,Symbol.iterator,{value:function(){return this}}),o;function u(i){return function(u){var c=[i,u];if(r)throw TypeError("Generator is already executing.");for(;o&&(o=0,c[0]&&(l=0)),l;)try{if(r=1,n&&(a=2&c[0]?n.return:c[0]?n.throw||((a=n.return)&&a.call(n),0):n.next)&&!(a=a.call(n,c[1])).done)return a;switch(n=0,a&&(c=[2&c[0],a.value]),c[0]){case 0:case 1:a=c;break;case 4:return l.label++,{value:c[1],done:!1};case 5:l.label++,n=c[1],c=[0];continue;case 7:c=l.ops.pop(),l.trys.pop();continue;default:if(!(a=(a=l.trys).length>0&&a[a.length-1])&&(6===c[0]||2===c[0])){l=0;continue}if(3===c[0]&&(!a||c[1]>a[0]&&c[1]<a[3])){l.label=c[1];break}if(6===c[0]&&l.label<a[1]){l.label=a[1],a=c;break}if(a&&l.label<a[2]){l.label=a[2],l.ops.push(c);break}a[2]&&l.ops.pop(),l.trys.pop();continue}c=t.call(e,l)}catch(e){c=[6,e],n=0}finally{r=a=0}if(5&c[0])throw c[1];return{value:c[0]?c[1]:void 0,done:!0}}}}var y={mode:"llm",enabledLlmServices:[],rateLimitPerMinute:60,maxRequestBodyMb:10,quotaEnabled:!1,defaultReservationOutputTokens:4096};function d(){var e=(0,o.useFlowContext)(),t=(0,i.k)(),r=p(l.Form.useForm(),1)[0],c=l.Form.useWatch("mode",r),s=p((0,n.useState)(!0),2),d=s[0],b=s[1],h=p((0,n.useState)(!1),2),v=h[0],g=h[1],E=p((0,n.useState)([]),2),S=E[0],w=E[1],I=p((0,n.useState)([]),2),k=I[0],F=I[1],P=p((0,n.useState)(),2),A=P[0],C=P[1],O=(0,n.useCallback)(function(){return m(function(){var t,n,a,l,o;return f(this,function(i){switch(i.label){case 0:b(!0),C(void 0),i.label=1;case 1:return i.trys.push([1,3,4,5]),[4,Promise.all([e.api.request({url:"aiApiConfig:get",method:"get"}),e.api.request({url:"ai:listLLMServices",method:"get"}),e.api.request({url:"aiEmployees:list",method:"get",params:{paginate:!1}})])];case 2:return n=(t=p.apply(void 0,[i.sent(),3]))[0],a=t[1],l=t[2],r.setFieldsValue(function(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{},n=Object.keys(r);"function"==typeof Object.getOwnPropertySymbols&&(n=n.concat(Object.getOwnPropertySymbols(r).filter(function(e){return Object.getOwnPropertyDescriptor(r,e).enumerable}))),n.forEach(function(t){var n;n=r[t],t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n})}return e}({},y,(0,u.m)(n,{}))),w((0,u.m)(a,[])),F((0,u.m)(l,[])),[3,5];case 3:return o=i.sent(),C((0,u.g)(o)),[3,5];case 4:return b(!1),[7];case 5:return[2]}})})()},[e.api,r]);(0,n.useEffect)(function(){O()},[O]);var x=S.map(function(e){return{label:e.title||e.name,value:e.name}}),L=k.map(function(e){return{label:e.nickname?"".concat(e.nickname," (").concat(e.username,")"):e.username,value:e.username}}),T="".concat(window.location.origin,"/api/ai-llm/v1");return a().createElement(l.Card,{title:t("Configuration"),loading:d},A?a().createElement(l.Alert,{type:"error",showIcon:!0,message:A,style:{marginBottom:16}}):null,a().createElement(l.Form,{form:r,layout:"vertical",style:{maxWidth:720},initialValues:y},a().createElement(l.Form.Item,{name:"mode",label:t("API mode"),rules:[{required:!0}]},a().createElement(l.Select,{options:[{label:t("Direct LLM"),value:"llm"},{label:t("AI Employee agent"),value:"agent"}]})),a().createElement(l.Form.Item,{name:"defaultLlmService",label:t("Default LLM service")},a().createElement(l.Select,{allowClear:!0,showSearch:!0,optionFilterProp:"label",options:x})),a().createElement(l.Form.Item,{name:"enabledLlmServices",label:t("Enabled LLM Services")},a().createElement(l.Select,{mode:"multiple",allowClear:!0,showSearch:!0,optionFilterProp:"label",options:x})),"agent"===c?a().createElement(l.Form.Item,{name:"defaultAiEmployee",label:t("Default AI Employee")},a().createElement(l.Select,{allowClear:!0,showSearch:!0,optionFilterProp:"label",placeholder:t("Select an AI Employee"),options:L})):null,a().createElement(l.Form.Item,{name:"rateLimitPerMinute",label:t("Rate Limit"),rules:[{required:!0}]},a().createElement(l.InputNumber,{min:1,style:{width:"100%"}})),a().createElement(l.Form.Item,{name:"maxRequestBodyMb",label:t("Max request body size (MB)"),extra:t("Raise this to accept inline base64 images. Base64 adds about 33% to the original file size."),rules:[{required:!0}]},a().createElement(l.InputNumber,{min:1,max:100,precision:0,style:{width:"100%"}})),a().createElement(l.Form.Item,{name:"quotaEnabled",label:t("Enable user quotas"),valuePropName:"checked"},a().createElement(l.Switch,null)),a().createElement(l.Form.Item,{name:"defaultReservationOutputTokens",label:t("Default reserved output tokens"),rules:[{required:!0}]},a().createElement(l.InputNumber,{min:1,style:{width:"100%"}})),a().createElement(l.Space,null,a().createElement(l.Button,{type:"primary",loading:v,onClick:function(){return m(function(){var n,a;return f(this,function(o){switch(o.label){case 0:return[4,r.validateFields()];case 1:n=o.sent(),g(!0),o.label=2;case 2:return o.trys.push([2,4,5,6]),[4,e.api.request({url:"aiApiConfig:save",method:"post",data:n})];case 3:return o.sent(),l.message.success(t("Configuration saved")),[3,6];case 4:return a=o.sent(),l.message.error("".concat(t("Failed to save configuration"),": ").concat((0,u.g)(a))),[3,6];case 5:return g(!1),[7];case 6:return[2]}})})()}},t("Save Configuration")),a().createElement(l.Button,{onClick:O},t("Refresh")))),a().createElement(l.Card,{title:t("Usage guide"),size:"small",style:{marginTop:24}},a().createElement(l.Alert,{type:"info",showIcon:!0,message:t("OpenAI-compatible endpoint"),description:a().createElement(l.Space,{direction:"vertical",size:4},a().createElement(l.Typography.Text,null,t("Base URL")),a().createElement(l.Typography.Text,{code:!0,copyable:!0},T),a().createElement(l.Typography.Text,null,t("Use a NocoBase API key as the Bearer token."))),style:{marginBottom:16}}),a().createElement(l.Typography.Paragraph,null,t("List available models")),a().createElement(l.Typography.Paragraph,{code:!0,copyable:!0},"curl ".concat(T,'/models -H "Authorization: Bearer <your-api-key>"')),a().createElement(l.Typography.Paragraph,null,t("Send a chat completion")),a().createElement(l.Typography.Paragraph,{code:!0,copyable:!0},"curl ".concat(T,'/chat/completions \\\n -H "Authorization: Bearer <your-api-key>" \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"<service>/<model>","messages":[{"role":"user","content":"Hello"}]}\''))))}},650:function(e,t,r){r.d(t,{k:function(){return l}});var n=r(694),a=JSON.parse('{"UU":"plugin-ai-api"}');function l(){var e=(0,n.useFlowEngine)();return function(t){return e.context.t(t,{ns:[a.UU,"client"]})}}},630:function(e,t,r){function n(e,t){var r,n;return e&&(void 0===e?"undefined":e&&"u">typeof Symbol&&e.constructor===Symbol?"symbol":typeof e)=="object"?null!=(r=null==(n=e.data)?void 0:n.data)?r:t:t}function a(e){var t;return(null!=(t=Error)&&"u">typeof Symbol&&t[Symbol.hasInstance]?!!t[Symbol.hasInstance](e):e instanceof t)?e.message:String(e)}r.d(t,{g:function(){return a},m:function(){return n}})}}]);