plugin-ai-api 1.1.1 → 1.1.2

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 (108) hide show
  1. package/README.md +51 -12
  2. package/dist/client/185.c47663fefaeb0e5b.js +10 -0
  3. package/dist/client/562.9012cfd1fa04303d.js +10 -0
  4. package/dist/client/685.b5b1e0a5b825d253.js +10 -0
  5. package/dist/client/index.js +1 -1
  6. package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
  7. package/dist/client-v2/562.db2984167250b1be.js +10 -0
  8. package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
  9. package/dist/client-v2/index.js +1 -1
  10. package/dist/externalVersion.js +8 -8
  11. package/dist/locale/en-US.json +175 -139
  12. package/dist/locale/vi-VN.json +40 -2
  13. package/dist/locale/zh-CN.json +40 -2
  14. package/dist/server/collections/ai-api-model-metadata.js +26 -0
  15. package/dist/server/collections/ai-api-response-records.js +101 -0
  16. package/dist/server/collections/ai-api-virtual-models.js +68 -0
  17. package/dist/server/middleware/response-record-resource.js +66 -0
  18. package/dist/server/middleware/role-permission.js +43 -18
  19. package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
  20. package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
  21. package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
  22. package/dist/server/plugin.js +66 -16
  23. package/dist/server/routes/chat-completions.js +38 -6
  24. package/dist/server/routes/completions.js +16 -4
  25. package/dist/server/routes/embeddings.js +25 -6
  26. package/dist/server/routes/models.js +29 -0
  27. package/dist/server/routes/responses.js +530 -0
  28. package/dist/server/routes/router.js +65 -10
  29. package/dist/server/usage.js +25 -4
  30. package/dist/server/utils/direct-llm-context.js +1 -1
  31. package/dist/server/utils/resolve-service.js +24 -0
  32. package/dist/server/utils/response-store.js +138 -0
  33. package/dist/server/utils/responses-format.js +686 -0
  34. package/dist/server/utils/responses-stream.js +330 -0
  35. package/dist/server/utils/virtual-models.js +238 -0
  36. package/dist/server/validation.js +44 -2
  37. package/dist/swagger.js +137 -0
  38. package/package.json +34 -32
  39. package/src/__tests__/locale.test.ts +43 -0
  40. package/src/client/__tests__/settings-registration.test.tsx +1 -0
  41. package/src/client/plugin.tsx +9 -1
  42. package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
  44. package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
  45. package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
  46. package/src/client-v2/plugin.tsx +8 -0
  47. package/src/locale/en-US.json +175 -139
  48. package/src/locale/vi-VN.json +40 -2
  49. package/src/locale/zh-CN.json +40 -2
  50. package/src/server/__tests__/embeddings.test.ts +184 -0
  51. package/src/server/__tests__/models.test.ts +21 -1
  52. package/src/server/__tests__/response-record-resource.test.ts +50 -0
  53. package/src/server/__tests__/response-store-integration.test.ts +341 -0
  54. package/src/server/__tests__/response-store.test.ts +195 -0
  55. package/src/server/__tests__/responses-contract.test.ts +469 -0
  56. package/src/server/__tests__/responses-format.test.ts +299 -0
  57. package/src/server/__tests__/responses-router.test.ts +182 -0
  58. package/src/server/__tests__/responses-streaming.test.ts +368 -0
  59. package/src/server/__tests__/responses.test.ts +462 -0
  60. package/src/server/__tests__/role-permission.test.ts +139 -0
  61. package/src/server/__tests__/seed-role-permission.test.ts +88 -0
  62. package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
  63. package/src/server/__tests__/usage-groups.test.ts +96 -0
  64. package/src/server/__tests__/usage-route.test.ts +1 -0
  65. package/src/server/__tests__/usage.test.ts +14 -0
  66. package/src/server/__tests__/validation.test.ts +66 -7
  67. package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
  68. package/src/server/collections/ai-api-model-metadata.ts +26 -0
  69. package/src/server/collections/ai-api-response-records.ts +77 -0
  70. package/src/server/collections/ai-api-virtual-models.ts +58 -0
  71. package/src/server/middleware/response-record-resource.ts +44 -0
  72. package/src/server/middleware/role-permission.ts +69 -35
  73. package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
  74. package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
  75. package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
  76. package/src/server/plugin.ts +84 -20
  77. package/src/server/resource/ai-api-config.ts +2 -1
  78. package/src/server/routes/agent-completions.ts +3 -0
  79. package/src/server/routes/chat-completions.ts +34 -10
  80. package/src/server/routes/completions.ts +16 -4
  81. package/src/server/routes/embeddings.ts +32 -10
  82. package/src/server/routes/models.ts +34 -0
  83. package/src/server/routes/responses.ts +640 -0
  84. package/src/server/routes/router.ts +81 -12
  85. package/src/server/services/__tests__/file-processor.test.ts +1 -0
  86. package/src/server/usage.ts +29 -2
  87. package/src/server/utils/app-observability.ts +1 -1
  88. package/src/server/utils/direct-llm-context.ts +2 -1
  89. package/src/server/utils/openai-format.ts +1 -0
  90. package/src/server/utils/resolve-service.ts +39 -1
  91. package/src/server/utils/response-store.ts +148 -0
  92. package/src/server/utils/responses-format.ts +974 -0
  93. package/src/server/utils/responses-stream.ts +384 -0
  94. package/src/server/utils/virtual-models.ts +320 -0
  95. package/src/server/validation.ts +49 -0
  96. package/src/swagger.ts +139 -0
  97. package/dist/client/562.44b16aad4718b4c7.js +0 -10
  98. package/dist/client/685.ae483e17b6b49c98.js +0 -10
  99. package/dist/client-v2/562.45d5c504433be38b.js +0 -10
  100. package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
  101. package/dist/server/collections/ai-api-user-permissions.js +0 -67
  102. package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
  103. package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
  104. package/dist/server/resource/ai-api-usage-groups.js +0 -168
  105. package/src/server/collections/ai-api-user-permissions.ts +0 -46
  106. package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
  107. package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
  108. package/src/server/resource/ai-api-usage-groups.ts +0 -171
@@ -0,0 +1,58 @@
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 { defineCollection } from '@nocobase/database';
11
+
12
+ /**
13
+ * Virtual model aliases (e.g. "auto") that the gateway resolves to a concrete
14
+ * LLM model based on the request shape and the caller's access scope.
15
+ *
16
+ * The per-capability lists are ordered: the resolver walks them top-down and
17
+ * picks the FIRST model the current user is permitted to use. A list left empty
18
+ * is derived from aiApiModelMetadata (capability flags + sortOrder). When no
19
+ * candidate is usable, `fallbackModel` is used when it is also permitted for
20
+ * the caller. An alias never widens the caller's model access.
21
+ */
22
+ export default defineCollection({
23
+ name: 'aiApiVirtualModels',
24
+ autoGenId: true,
25
+ fields: [
26
+ {
27
+ name: 'name',
28
+ type: 'string',
29
+ unique: true,
30
+ allowNull: false,
31
+ comment: 'Alias the client passes as the model id, e.g. "auto".',
32
+ },
33
+ {
34
+ name: 'mode',
35
+ type: 'string',
36
+ defaultValue: 'chat',
37
+ comment: 'Endpoint family this alias serves: chat | embedding.',
38
+ },
39
+ {
40
+ name: 'fallbackModel',
41
+ type: 'string',
42
+ allowNull: false,
43
+ comment:
44
+ 'Concrete "service/modelId" used when no capability bucket candidate is usable and fallback is permitted.',
45
+ },
46
+ {
47
+ name: 'visionModels',
48
+ type: 'json',
49
+ defaultValue: [],
50
+ comment: 'Ordered "service/modelId" list for vision requests.',
51
+ },
52
+ { name: 'toolModels', type: 'json', defaultValue: [], comment: 'Ordered list for tool-calling requests.' },
53
+ { name: 'reasoningModels', type: 'json', defaultValue: [], comment: 'Ordered list for reasoning-tier requests.' },
54
+ { name: 'cheapModels', type: 'json', defaultValue: [], comment: 'Ordered list for cheap-tier requests.' },
55
+ { name: 'generalModels', type: 'json', defaultValue: [], comment: 'Ordered list for general requests.' },
56
+ { name: 'enabled', type: 'boolean', defaultValue: true, index: true },
57
+ ],
58
+ });
@@ -0,0 +1,44 @@
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, Next } from '@nocobase/actions';
11
+
12
+ export const RESPONSE_RECORD_COLLECTION = 'aiApiResponseRecords';
13
+
14
+ type RepositoryAwareContext = Context & {
15
+ getCurrentRepository?: () => { targetCollection?: { name?: string } } | null;
16
+ };
17
+
18
+ function routeCandidates(ctx: Context): string[] {
19
+ const params = ctx.action?.params;
20
+ const candidates = [ctx.action?.resourceName, params?.resourceName, params?.associatedName, params?.targetCollection];
21
+ return candidates.filter((value): value is string => typeof value === 'string').flatMap((value) => value.split('.'));
22
+ }
23
+
24
+ export function targetsResponseRecordCollection(ctx: Context): boolean {
25
+ const repositoryContext = ctx as RepositoryAwareContext;
26
+ if (repositoryContext.getCurrentRepository) {
27
+ try {
28
+ if (repositoryContext.getCurrentRepository()?.targetCollection?.name === RESPONSE_RECORD_COLLECTION) return true;
29
+ } catch {
30
+ // Some custom resources have no repository. Fall back to the resolved route fields.
31
+ }
32
+ }
33
+ return routeCandidates(ctx).includes(RESPONSE_RECORD_COLLECTION);
34
+ }
35
+
36
+ export function blockResponseRecordResource() {
37
+ return async (ctx: Context, next: Next): Promise<void> => {
38
+ if (targetsResponseRecordCollection(ctx)) {
39
+ ctx.throw(404, 'Not Found');
40
+ return;
41
+ }
42
+ await next();
43
+ };
44
+ }
@@ -11,44 +11,80 @@ import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError } from '../utils/openai-format';
12
12
 
13
13
  const PERMISSION_TTL_MS = 15_000;
14
- const permissionCache = new Map<string, { record: any; expiresAt: number }>();
14
+
15
+ export interface RolePermissionRecord {
16
+ roleName?: string;
17
+ enabled?: boolean;
18
+ allowAllEmployees?: boolean;
19
+ allowedEmployees?: string[];
20
+ }
21
+
22
+ const permissionCache = new Map<string, { record: RolePermissionRecord | null; expiresAt: number }>();
15
23
 
16
24
  export function invalidateRolePermissionCache(roleName?: string): void {
17
- if (roleName) permissionCache.delete(roleName);
18
- else permissionCache.clear();
25
+ if (!roleName) {
26
+ permissionCache.clear();
27
+ return;
28
+ }
29
+ const suffix = `:role:${roleName}`;
30
+ for (const key of permissionCache.keys()) {
31
+ if (key.endsWith(suffix)) permissionCache.delete(key);
32
+ }
33
+ }
34
+
35
+ function unwrapRecord(raw: unknown): RolePermissionRecord | null {
36
+ if (!raw) return null;
37
+ if (typeof (raw as { get?: unknown }).get === 'function') {
38
+ const model = raw as { get: (key: string) => unknown };
39
+ return {
40
+ roleName: model.get('roleName') as string | undefined,
41
+ enabled: model.get('enabled') as boolean | undefined,
42
+ allowAllEmployees: model.get('allowAllEmployees') as boolean | undefined,
43
+ allowedEmployees: model.get('allowedEmployees') as string[] | undefined,
44
+ };
45
+ }
46
+ return raw as RolePermissionRecord;
47
+ }
48
+
49
+ async function loadRolePermission(ctx: Context, roleName: string): Promise<RolePermissionRecord | null> {
50
+ const cacheKey = `${ctx.app?.name ?? 'main'}:role:${roleName}`;
51
+ const cached = permissionCache.get(cacheKey);
52
+ if (cached && cached.expiresAt > Date.now()) {
53
+ return cached.record;
54
+ }
55
+ const record = unwrapRecord(await ctx.db.getRepository('aiApiRolePermissions').findOne({ filter: { roleName } }));
56
+ permissionCache.set(cacheKey, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
57
+ return record;
19
58
  }
20
59
 
21
60
  /**
22
- * Check whether the authenticated role is allowed to use the AI API.
23
- * Loads the permission record and stores it in ctx.state.aiApiRolePermission.
61
+ * Check whether the authenticated roles are allowed to use the AI API.
62
+ *
63
+ * Every role — including root — must hold an enabled aiApiRolePermissions row;
64
+ * there is no special-case bypass, so a leaked API key stays scoped to whatever
65
+ * its role was granted in Settings → Users & Permissions → [Role] → AI API.
66
+ *
67
+ * Multi-role callers are evaluated with union semantics: the request is allowed
68
+ * when ANY assigned role is enabled, and the enabled records are stored in
69
+ * ctx.state.aiApiRolePermissions for downstream handlers (see checkEmployeeAccess).
24
70
  *
25
71
  * Returns true if access is allowed (caller may proceed).
26
72
  * Returns false if access is denied (403 already written to ctx, caller must return).
27
- *
28
- * The 'root' and 'admin' roles always bypass the check.
29
73
  */
30
74
  export async function checkRolePermission(ctx: Context): Promise<boolean> {
31
- const roleName = ctx.state.currentRoles?.[0] || 'member';
32
-
33
- // root / admin always allowed
34
- if (roleName === 'root' || roleName === 'admin') {
35
- return true;
36
- }
75
+ const roleNames = ctx.state.currentRoles?.length
76
+ ? (ctx.state.currentRoles as string[])
77
+ : [ctx.state.currentRole || 'member'];
37
78
 
38
- const cached = permissionCache.get(roleName);
39
- const record =
40
- cached && cached.expiresAt > Date.now()
41
- ? cached.record
42
- : await ctx.db.getRepository('aiApiRolePermissions').findOne({ filter: { roleName } });
43
- if (!cached || cached.expiresAt <= Date.now()) {
44
- permissionCache.set(roleName, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
45
- }
79
+ const records = (await Promise.all(roleNames.map((name) => loadRolePermission(ctx, name)))).filter(
80
+ (record): record is RolePermissionRecord => !!record?.enabled,
81
+ );
46
82
 
47
- if (!record?.enabled) {
83
+ if (!records.length) {
48
84
  ctx.status = 403;
49
85
  ctx.body = toOpenAIError(
50
86
  403,
51
- `Role '${roleName}' is not authorized to use the AI API. ` +
87
+ `None of the roles [${roleNames.join(', ')}] is authorized to use the AI API. ` +
52
88
  `An admin must enable access in Settings → Users & Permissions → [Role] → AI API.`,
53
89
  'permission_denied',
54
90
  'role_not_permitted',
@@ -56,24 +92,22 @@ export async function checkRolePermission(ctx: Context): Promise<boolean> {
56
92
  return false;
57
93
  }
58
94
 
59
- // Store for downstream handlers
60
- ctx.state.aiApiRolePermission = record;
95
+ // Store for downstream handlers (employee scoping, usage attribution).
96
+ ctx.state.aiApiRolePermissions = records;
61
97
  return true;
62
98
  }
63
99
 
64
100
  /**
65
- * Check whether the current role is allowed to use a specific AI Employee.
66
- * Must be called after checkRolePermission (so ctx.state.aiApiRolePermission is set).
101
+ * Check whether the current roles may use a specific AI Employee.
102
+ * Must be called after checkRolePermission (so ctx.state.aiApiRolePermissions is set).
67
103
  *
68
104
  * Returns true when:
69
- * - Role is admin/root (no permission record stored)
70
- * - allowAllEmployees is true
71
- * - The employeeUsername is in the allowedEmployees list
105
+ * - Any enabled role has allowAllEmployees=true, or
106
+ * - The employeeUsername appears in the union of the enabled roles' allowedEmployees lists
72
107
  */
73
108
  export function checkEmployeeAccess(ctx: Context, employeeUsername: string): boolean {
74
- const perm = ctx.state.aiApiRolePermission;
75
- // admin/root paths have no record stored → always allowed
76
- if (!perm) return true;
77
- if (perm.allowAllEmployees) return true;
78
- return ((perm.allowedEmployees as string[]) || []).includes(employeeUsername);
109
+ const perms = (ctx.state.aiApiRolePermissions as RolePermissionRecord[] | undefined) || [];
110
+ return perms.some(
111
+ (perm) => perm.allowAllEmployees || ((perm.allowedEmployees as string[]) || []).includes(employeeUsername),
112
+ );
79
113
  }
@@ -0,0 +1,56 @@
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 { Migration } from '@nocobase/server';
11
+
12
+ /**
13
+ * The default usage group is a pure fallback: users with no aiApiGroupMembers row
14
+ * resolve to it automatically at runtime. Earlier migrations (legacy quota-policy
15
+ * backfill and group-delete reassignment) wrote explicit member rows pointing at
16
+ * the default group. Those rows are redundant, hide users from the "unassigned"
17
+ * filter, and can no longer be created through the guarded create path. Drop them
18
+ * so the default group holds no explicit members.
19
+ */
20
+ export default class RemoveDefaultGroupMembers extends Migration {
21
+ // afterSync: aiApiGroupMembers / aiApiUsageGroups must be registered and synced.
22
+ on = 'afterSync' as const;
23
+
24
+ async up() {
25
+ const groupCollection = this.db.getCollection('aiApiUsageGroups');
26
+ const memberCollection = this.db.getCollection('aiApiGroupMembers');
27
+ if (!groupCollection || !memberCollection) return;
28
+ if (!(await groupCollection.existsInDb()) || !(await memberCollection.existsInDb())) return;
29
+
30
+ const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
31
+ filter: { isDefault: true },
32
+ });
33
+ if (!defaultGroup) return;
34
+
35
+ const groupId = defaultGroup.get('id');
36
+ // Destroy one by one so per-row destroy events fire; member counts here are small.
37
+ const members = await this.db.getRepository('aiApiGroupMembers').find({
38
+ filter: { groupId },
39
+ });
40
+ for (const member of members) {
41
+ await this.db.getRepository('aiApiGroupMembers').destroy({ filterByTk: member.get('id') });
42
+ }
43
+
44
+ if (members.length) {
45
+ this.app.logger.info(
46
+ `[ai-api] Removed ${members.length} explicit member row(s) from the default usage group; those users now fall back to it implicitly.`,
47
+ );
48
+ }
49
+ }
50
+
51
+ async down() {
52
+ // Irreversible: the dropped rows carried no extra data, and re-adding them would
53
+ // re-introduce the inconsistency this migration removes. Users still resolve to
54
+ // the default group implicitly, so behaviour is unchanged.
55
+ }
56
+ }
@@ -0,0 +1,46 @@
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 { Migration } from '@nocobase/server';
11
+
12
+ /**
13
+ * The AI API gateway no longer bypasses permission checks for any role, root and admin
14
+ * included: every role needs an enabled aiApiRolePermissions row. Existing deployments
15
+ * rely on root's and admin's implicit access, so seed both rows here — otherwise every
16
+ * root/admin API key would start failing with 403 right after the upgrade.
17
+ * Rows an admin already created are left untouched.
18
+ */
19
+ export default class SeedDefaultRolePermissions extends Migration {
20
+ // afterSync: aiApiRolePermissions must be registered and synced before we write to it.
21
+ on = 'afterSync' as const;
22
+
23
+ async up() {
24
+ const collection = this.db.getCollection('aiApiRolePermissions');
25
+ if (!collection || !(await collection.existsInDb())) return;
26
+
27
+ const repository = this.db.getRepository('aiApiRolePermissions');
28
+ const rolesToSeed = [
29
+ { roleName: 'root', enabled: true, allowAllEmployees: true, allowedEmployees: [] },
30
+ { roleName: 'admin', enabled: true, allowAllEmployees: true, allowedEmployees: [] },
31
+ ];
32
+ for (const values of rolesToSeed) {
33
+ const existing = await repository.findOne({ filter: { roleName: values.roleName } });
34
+ if (existing) continue;
35
+ await repository.create({ values });
36
+ this.app.logger.info(
37
+ `[ai-api] Seeded aiApiRolePermissions for role ${values.roleName} (the built-in bypass was removed).`,
38
+ );
39
+ }
40
+ }
41
+
42
+ async down() {
43
+ // Keep the rows on rollback: deleting them would lock root/admin out of the gateway,
44
+ // and the rows are harmless for older code paths that bypass the check anyway.
45
+ }
46
+ }
@@ -0,0 +1,162 @@
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 { Migration } from '@nocobase/server';
11
+
12
+ const SAMPLE_IDS = ['resp_sample_welcome', 'resp_sample_followup'];
13
+
14
+ export default class SeedSampleResponseRecord extends Migration {
15
+ on = 'afterSync' as const;
16
+
17
+ async up() {
18
+ const collection = this.db.getCollection('aiApiResponseRecords');
19
+ if (!collection || !(await collection.existsInDb())) return;
20
+
21
+ // Never seed demo content in production unless the operator opts in explicitly.
22
+ if (process.env.NODE_ENV === 'production' && process.env.AI_API_SEED_SAMPLE_RESPONSES !== 'true') {
23
+ this.app.logger.info(
24
+ '[ai-api] Skipped sample response records in production (set AI_API_SEED_SAMPLE_RESPONSES=true to enable).',
25
+ );
26
+ return;
27
+ }
28
+
29
+ const rootUser = await this.db.getRepository('users').findOne({ filter: { 'roles.name': 'root' } });
30
+ if (!rootUser) {
31
+ this.app.logger.warn('[ai-api] Skipped sample response records because no root user exists.');
32
+ return;
33
+ }
34
+ const repository = this.db.getRepository('aiApiResponseRecords');
35
+
36
+ const now = Math.floor(Date.now() / 1000);
37
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
38
+ const firstOutput = {
39
+ id: SAMPLE_IDS[0],
40
+ object: 'response',
41
+ created_at: now,
42
+ completed_at: now,
43
+ status: 'completed',
44
+ error: null,
45
+ incomplete_details: null,
46
+ instructions: null,
47
+ max_output_tokens: null,
48
+ model: 'sample/gpt-4',
49
+ output: [
50
+ {
51
+ id: 'msg_sample_welcome',
52
+ type: 'message',
53
+ status: 'completed',
54
+ role: 'assistant',
55
+ content: [
56
+ {
57
+ type: 'output_text',
58
+ text: 'Hello! Welcome to the NocoBase AI API. How can I help you today?',
59
+ annotations: [],
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ output_text: 'Hello! Welcome to the NocoBase AI API. How can I help you today?',
65
+ parallel_tool_calls: true,
66
+ previous_response_id: null,
67
+ reasoning: null,
68
+ service_tier: 'default',
69
+ temperature: null,
70
+ text: null,
71
+ tool_choice: 'auto',
72
+ tools: [],
73
+ top_p: null,
74
+ truncation: 'disabled',
75
+ usage: {
76
+ input_tokens: 15,
77
+ input_tokens_details: { cached_tokens: 0 },
78
+ output_tokens: 20,
79
+ output_tokens_details: { reasoning_tokens: 0 },
80
+ total_tokens: 35,
81
+ },
82
+ metadata: { sample: 'true', purpose: 'documentation' },
83
+ };
84
+
85
+ const firstValues = {
86
+ responseId: SAMPLE_IDS[0],
87
+ userId: rootUser.get('id'),
88
+ model: firstOutput.model,
89
+ input: 'Hello! I am testing the Responses API.',
90
+ output: firstOutput,
91
+ previousResponseId: null,
92
+ metadata: firstOutput.metadata,
93
+ expiresAt,
94
+ };
95
+
96
+ const secondOutput = {
97
+ ...firstOutput,
98
+ id: SAMPLE_IDS[1],
99
+ created_at: now + 60,
100
+ completed_at: now + 60,
101
+ output: [
102
+ {
103
+ id: 'msg_sample_followup',
104
+ type: 'message',
105
+ status: 'completed',
106
+ role: 'assistant',
107
+ content: [
108
+ {
109
+ type: 'output_text',
110
+ text: 'I can help with questions, code, data analysis, and more.',
111
+ annotations: [],
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ output_text: 'I can help with questions, code, data analysis, and more.',
117
+ previous_response_id: SAMPLE_IDS[0],
118
+ usage: {
119
+ input_tokens: 25,
120
+ input_tokens_details: { cached_tokens: 0 },
121
+ output_tokens: 15,
122
+ output_tokens_details: { reasoning_tokens: 0 },
123
+ total_tokens: 40,
124
+ },
125
+ };
126
+ const secondValues = {
127
+ responseId: SAMPLE_IDS[1],
128
+ userId: rootUser.get('id'),
129
+ model: secondOutput.model,
130
+ input: 'What can you help me with?',
131
+ output: secondOutput,
132
+ previousResponseId: SAMPLE_IDS[0],
133
+ metadata: secondOutput.metadata,
134
+ expiresAt,
135
+ };
136
+
137
+ // Create each missing sample independently inside one transaction, so a rerun after a
138
+ // partial failure still seeds the remaining record without duplicating the existing one.
139
+ const candidates = [firstValues, secondValues];
140
+ await this.db.sequelize.transaction(async (transaction) => {
141
+ for (const values of candidates) {
142
+ const exists = await repository.findOne({ filter: { responseId: values.responseId }, transaction });
143
+ if (!exists) {
144
+ await repository.create({ values, transaction });
145
+ }
146
+ }
147
+ });
148
+ this.app.logger.info('[ai-api] Seeded sample Responses API records for the root user.');
149
+ }
150
+
151
+ async down() {
152
+ const repository = this.db.getRepository('aiApiResponseRecords');
153
+ const records = await repository.find({ filter: { responseId: { $in: SAMPLE_IDS } } });
154
+ for (const record of records) {
155
+ const metadata = record.get('metadata');
156
+ // Only remove records we actually seeded — a user-owned record may reuse a sample id.
157
+ if (metadata && typeof metadata === 'object' && (metadata as Record<string, unknown>).sample === 'true') {
158
+ await repository.destroy({ filterByTk: record.get('id') });
159
+ }
160
+ }
161
+ }
162
+ }