plugin-ai-api 1.0.25 → 1.1.0

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 (115) 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.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
  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.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
  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/locale/en-US.json +26 -8
  17. package/dist/locale/vi-VN.json +26 -8
  18. package/dist/locale/zh-CN.json +26 -8
  19. package/dist/server/billing.js +25 -32
  20. package/dist/server/collections/ai-api-config.js +1 -7
  21. package/dist/server/collections/ai-api-group-members.js +62 -0
  22. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  24. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  25. package/dist/server/collections/ai-api-usage-records.js +1 -0
  26. package/dist/server/middleware/rate-limit.js +7 -6
  27. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  28. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  29. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  30. package/dist/server/plugin.js +90 -22
  31. package/dist/server/quota-groups.js +108 -0
  32. package/dist/server/resource/ai-api-config.js +0 -3
  33. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  34. package/dist/server/routes/agent-completions.js +2 -1
  35. package/dist/server/routes/chat-completions.js +32 -32
  36. package/dist/server/routes/completions.js +16 -19
  37. package/dist/server/routes/embeddings.js +2 -1
  38. package/dist/server/routes/models.js +2 -1
  39. package/dist/server/routes/router.js +3 -2
  40. package/dist/server/services/file-processor.js +186 -22
  41. package/dist/server/usage.js +5 -1
  42. package/dist/server/utils/direct-llm-context.js +13 -11
  43. package/dist/server/utils/rate-limiter.js +1 -1
  44. package/dist/server/utils/request-cache.js +61 -0
  45. package/dist/server/utils/resolve-service.js +2 -1
  46. package/dist/server/utils/user-permissions.js +25 -39
  47. package/dist/server/validation.js +7 -0
  48. package/dist/swagger.js +6 -7
  49. package/package.json +1 -1
  50. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  51. package/src/client/plugin.tsx +5 -16
  52. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  53. package/src/client-v2/locale.ts +3 -1
  54. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  55. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  56. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  57. package/src/client-v2/plugin.tsx +4 -13
  58. package/src/constants.ts +0 -7
  59. package/src/locale/en-US.json +26 -8
  60. package/src/locale/vi-VN.json +26 -8
  61. package/src/locale/zh-CN.json +26 -8
  62. package/src/server/__tests__/billing-quota.test.ts +28 -9
  63. package/src/server/__tests__/direct-llm-context.test.ts +122 -4
  64. package/src/server/__tests__/file-processor.test.ts +225 -0
  65. package/src/server/__tests__/models.test.ts +1 -1
  66. package/src/server/__tests__/permission-sync.test.ts +34 -35
  67. package/src/server/__tests__/usage-groups.test.ts +160 -0
  68. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  69. package/src/server/__tests__/usage-route.test.ts +262 -2
  70. package/src/server/__tests__/usage.test.ts +38 -0
  71. package/src/server/__tests__/user-permissions.test.ts +214 -133
  72. package/src/server/__tests__/validation.test.ts +11 -0
  73. package/src/server/billing.ts +30 -38
  74. package/src/server/collections/ai-api-config.ts +1 -7
  75. package/src/server/collections/ai-api-group-members.ts +41 -0
  76. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  77. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  78. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  79. package/src/server/collections/ai-api-usage-records.ts +1 -0
  80. package/src/server/middleware/rate-limit.ts +10 -12
  81. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  82. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  83. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  84. package/src/server/plugin.ts +101 -30
  85. package/src/server/quota-groups.ts +117 -0
  86. package/src/server/resource/ai-api-config.ts +0 -3
  87. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  88. package/src/server/routes/agent-completions.ts +2 -1
  89. package/src/server/routes/chat-completions.ts +39 -36
  90. package/src/server/routes/completions.ts +18 -21
  91. package/src/server/routes/embeddings.ts +2 -1
  92. package/src/server/routes/models.ts +4 -3
  93. package/src/server/routes/router.ts +4 -3
  94. package/src/server/services/file-processor.ts +214 -24
  95. package/src/server/usage.ts +5 -1
  96. package/src/server/utils/direct-llm-context.ts +20 -11
  97. package/src/server/utils/rate-limiter.ts +1 -1
  98. package/src/server/utils/request-cache.ts +59 -0
  99. package/src/server/utils/resolve-service.ts +2 -1
  100. package/src/server/utils/user-permissions.ts +49 -69
  101. package/src/server/validation.ts +7 -0
  102. package/src/swagger.ts +7 -8
  103. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  104. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  105. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  106. package/dist/client/902.e74518750f1e4201.js +0 -10
  107. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  108. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  109. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  110. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  111. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  112. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  113. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  114. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  115. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -99,6 +99,8 @@
99
99
  "Leave empty to not override": "Để trống nếu không override",
100
100
  "Total input + output token capacity reported to clients.": "Tổng dung lượng token (input + output) trả về cho client.",
101
101
  "Maximum output tokens reported to clients.": "Số token output tối đa trả về cho client.",
102
+ "Initial system prompt": "System prompt khởi tạo",
103
+ "Prepended as the first system message, before any system prompt sent by the client. If the client sends no system prompt, this becomes the system prompt sent to the provider.": "Được thêm vào làm system message đầu tiên, đứng trước mọi system prompt mà client gửi sang. Nếu client không gửi system prompt, đây sẽ là system prompt được gửi tới provider.",
102
104
  "AI API": "AI API",
103
105
  "Allow this role to use the AI API": "Cho phép vai trò này sử dụng AI API",
104
106
  "Allow all AI Employees": "Cho phép tất cả AI Employee",
@@ -106,15 +108,31 @@
106
108
  "Select allowed AI Employees": "Chọn AI Employee được phép",
107
109
  "Max request body size (MB)": "Giới hạn kích thước request body (MB)",
108
110
  "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "Tăng giá trị này để nhận ảnh base64 gửi trực tiếp. Base64 làm tăng khoảng 33% so với kích thước tệp gốc.",
109
- "User LLM permissions": "Phân quyền LLM theo người dùng",
110
- "Add permission": "Thêm phân quyền",
111
- "Edit permission": "Sửa phân quyền",
112
- "Delete this permission?": "Xoá phân quyền này?",
111
+ "Usage groups": "Nhóm usage",
112
+ "Add group": "Thêm nhóm",
113
+ "Edit group": "Sửa nhóm",
114
+ "Delete this group?": "Xóa nhóm này?",
115
+ "Mode": "Chế độ",
116
+ "Share": "Chia sẻ",
117
+ "Per user": "Theo người dùng",
118
+ "Rate limit per minute": "Giới hạn request/phút",
119
+ "Members": "Thành viên",
120
+ "Add member": "Thêm thành viên",
121
+ "Member added": "Đã thêm thành viên",
122
+ "Member removed": "Đã xóa thành viên",
123
+ "Remove member?": "Xóa thành viên?",
124
+ "Remove": "Xóa",
125
+ "Search group by user": "Tìm nhóm theo người dùng",
126
+ "User belongs to": "Người dùng thuộc nhóm",
127
+ "User not found": "Không tìm thấy người dùng",
128
+ "Default": "Mặc định",
113
129
  "Allowed LLM services": "Dịch vụ LLM được phép",
114
130
  "Allow all models": "Cho phép tất cả model",
115
131
  "Allowed models": "Model được phép",
116
- "No service allowed": "Không được phép dịch vụ nào",
117
- "All models of allowed services": "Tất cả model của các dịch vụ được phép",
118
- "Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.": "Người dùng có trong danh sách này chỉ được dùng các dịch vụ được chọn bên dưới. Người dùng không có bản ghi sẽ dùng theo cấu hình chung.",
119
- "Only services also enabled in the general configuration take effect.": "Chỉ những dịch vụ đồng thời được bật trong cấu hình chung mới có hiệu lực."
132
+ "Model access": "Quyền truy cập model",
133
+ "All models": "Tất cả model",
134
+ "All services": "Tất cả dịch vụ",
135
+ "No models": "Không model nào",
136
+ "Leave empty to allow every service enabled in the general configuration.": "Để trống để cho phép mọi dịch vụ đang được bật trong cấu hình chung.",
137
+ "Users who do not belong to any other group automatically use this default group — no need to add members.": "Người dùng không thuộc nhóm nào khác sẽ tự động dùng nhóm mặc định này — không cần thêm thành viên."
120
138
  }
@@ -99,6 +99,8 @@
99
99
  "Leave empty to not override": "留空则不覆盖",
100
100
  "Total input + output token capacity reported to clients.": "返回给客户端的输入+输出 token 总容量。",
101
101
  "Maximum output tokens reported to clients.": "返回给客户端的最大输出 token 数。",
102
+ "Initial system prompt": "初始系统提示词",
103
+ "Prepended as the first system message, before any system prompt sent by the client. If the client sends no system prompt, this becomes the system prompt sent to the provider.": "作为第一条 system 消息插入到客户端发送的任何 system 提示词之前。如果客户端未发送 system 提示词,此提示词将作为发送给提供商的 system 提示词。",
102
104
  "AI API": "AI API",
103
105
  "Allow this role to use the AI API": "允许此角色使用 AI API",
104
106
  "Allow all AI Employees": "允许所有 AI 员工",
@@ -106,15 +108,31 @@
106
108
  "Select allowed AI Employees": "选择允许的 AI 员工",
107
109
  "Max request body size (MB)": "请求体大小上限(MB)",
108
110
  "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "调高此值以接收内联 base64 图片。base64 编码会使体积增加约 33%。",
109
- "User LLM permissions": "用户 LLM 权限",
110
- "Add permission": "添加权限",
111
- "Edit permission": "编辑权限",
112
- "Delete this permission?": "确定删除此权限?",
111
+ "Usage groups": "用量组",
112
+ "Add group": "添加分组",
113
+ "Edit group": "编辑分组",
114
+ "Delete this group?": "删除此分组?",
115
+ "Mode": "模式",
116
+ "Share": "共享",
117
+ "Per user": "按用户",
118
+ "Rate limit per minute": "每分钟速率限制",
119
+ "Members": "成员",
120
+ "Add member": "添加成员",
121
+ "Member added": "成员已添加",
122
+ "Member removed": "成员已移除",
123
+ "Remove member?": "移除成员?",
124
+ "Remove": "移除",
125
+ "Search group by user": "按用户搜索分组",
126
+ "User belongs to": "用户属于",
127
+ "User not found": "未找到用户",
128
+ "Default": "默认",
113
129
  "Allowed LLM services": "允许的 LLM 服务",
114
130
  "Allow all models": "允许所有模型",
115
131
  "Allowed models": "允许的模型",
116
- "No service allowed": "未允许任何服务",
117
- "All models of allowed services": "允许服务下的所有模型",
118
- "Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.": "此处列出的用户仅能使用下方所选的服务;没有记录的用户按通用配置处理。",
119
- "Only services also enabled in the general configuration take effect.": "仅当服务同时在通用配置中启用时才会生效。"
132
+ "Model access": "模型访问",
133
+ "All models": "所有模型",
134
+ "All services": "所有服务",
135
+ "No models": "未允许任何模型",
136
+ "Leave empty to allow every service enabled in the general configuration.": "留空则允许通用配置中已启用的所有服务。",
137
+ "Users who do not belong to any other group automatically use this default group — no need to add members.": "不属于其他分组的用户会自动使用此默认分组,无需手动添加成员。"
120
138
  }
@@ -3,7 +3,7 @@ import { createMockDatabase, type Database } from '@nocobase/database';
3
3
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
4
  import { AiApiQuotaError, finalizeLlmBilling, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
5
5
 
6
- describe('AI API user quota reservation', () => {
6
+ describe('AI API group quota reservation', () => {
7
7
  let db: Database;
8
8
 
9
9
  beforeEach(async () => {
@@ -30,9 +30,12 @@ describe('AI API user quota reservation', () => {
30
30
  ],
31
31
  });
32
32
  db.collection({
33
- name: 'aiApiUserQuotaPolicies',
33
+ name: 'aiApiUsageGroups',
34
34
  fields: [
35
- { name: 'userId', type: 'bigInt' },
35
+ { name: 'name', type: 'string' },
36
+ { name: 'isDefault', type: 'boolean' },
37
+ { name: 'quotaMode', type: 'string' },
38
+ { name: 'rateLimitPerMinute', type: 'integer' },
36
39
  { name: 'enabled', type: 'boolean' },
37
40
  { name: 'periodType', type: 'string' },
38
41
  { name: 'timezone', type: 'string' },
@@ -42,12 +45,21 @@ describe('AI API user quota reservation', () => {
42
45
  { name: 'currency', type: 'string' },
43
46
  { name: 'rejectUnpricedModel', type: 'boolean' },
44
47
  { name: 'missingUsageBehavior', type: 'string' },
48
+ { name: 'contextOverflowBehavior', type: 'string' },
45
49
  ],
46
50
  });
47
51
  db.collection({
48
- name: 'aiApiUserQuotaBuckets',
52
+ name: 'aiApiGroupMembers',
49
53
  fields: [
50
- { name: 'policyId', type: 'bigInt' },
54
+ { name: 'groupId', type: 'bigInt' },
55
+ { name: 'userId', type: 'bigInt' },
56
+ ],
57
+ indexes: [{ fields: ['userId'], unique: true }],
58
+ });
59
+ db.collection({
60
+ name: 'aiApiGroupQuotaBuckets',
61
+ fields: [
62
+ { name: 'groupId', type: 'bigInt' },
51
63
  { name: 'userId', type: 'bigInt' },
52
64
  { name: 'periodStart', type: 'datetimeTz' },
53
65
  { name: 'periodEnd', type: 'datetimeTz' },
@@ -58,7 +70,7 @@ describe('AI API user quota reservation', () => {
58
70
  { name: 'reservedTokens', type: 'bigInt' },
59
71
  { name: 'reservedCost', type: 'decimal', precision: 20, scale: 8 },
60
72
  ],
61
- indexes: [{ fields: ['policyId', 'periodStart'], unique: true }],
73
+ indexes: [{ fields: ['groupId', 'userId', 'periodStart'], unique: true }],
62
74
  });
63
75
  await db.sync({ force: true });
64
76
  await db.getRepository('aiApiConfig').create({
@@ -76,9 +88,12 @@ describe('AI API user quota reservation', () => {
76
88
  effectiveFrom: new Date('2020-01-01T00:00:00Z'),
77
89
  },
78
90
  });
79
- await db.getRepository('aiApiUserQuotaPolicies').create({
91
+ const group = await db.getRepository('aiApiUsageGroups').create({
80
92
  values: {
81
- userId: 7,
93
+ name: 'Default',
94
+ isDefault: true,
95
+ quotaMode: 'per_user',
96
+ rateLimitPerMinute: 60,
82
97
  enabled: true,
83
98
  periodType: 'monthly',
84
99
  timezone: 'UTC',
@@ -88,8 +103,12 @@ describe('AI API user quota reservation', () => {
88
103
  currency: 'USD',
89
104
  rejectUnpricedModel: true,
90
105
  missingUsageBehavior: 'use_reserved',
106
+ contextOverflowBehavior: 'reject',
91
107
  },
92
108
  });
109
+ await db.getRepository('aiApiGroupMembers').create({
110
+ values: { groupId: group.get('id'), userId: 7 },
111
+ });
93
112
  });
94
113
 
95
114
  afterEach(async () => {
@@ -126,7 +145,7 @@ describe('AI API user quota reservation', () => {
126
145
  );
127
146
  expect(finalized).toMatchObject({ estimatedCost: '0.00012500', costStatus: 'calculated' });
128
147
 
129
- const bucket = await db.getRepository('aiApiUserQuotaBuckets').findOne();
148
+ const bucket = await db.getRepository('aiApiGroupQuotaBuckets').findOne();
130
149
  expect(String(bucket?.get('requestCount'))).toBe('1');
131
150
  expect(String(bucket?.get('totalTokens'))).toBe('15');
132
151
  expect(String(bucket?.get('reservedRequests'))).toBe('0');
@@ -17,11 +17,33 @@ function context({ behavior = 'reject', metadata = { contextWindow: 120, maxComp
17
17
  findOne: vi.fn().mockResolvedValue({ get: (key: string) => metadata[key as keyof typeof metadata] }),
18
18
  };
19
19
  }
20
- if (name === 'aiApiUserQuotaPolicies') {
20
+ if (name === 'aiApiGroupMembers') {
21
21
  return {
22
- findOne: vi
23
- .fn()
24
- .mockResolvedValue({ get: (key: string) => (key === 'contextOverflowBehavior' ? behavior : undefined) }),
22
+ findOne: vi.fn().mockResolvedValue(null),
23
+ };
24
+ }
25
+ if (name === 'aiApiUsageGroups') {
26
+ return {
27
+ findOne: vi.fn().mockResolvedValue({
28
+ get: (key?: string) => {
29
+ const record: Record<string, unknown> = {
30
+ id: 1,
31
+ name: 'Default',
32
+ isDefault: true,
33
+ quotaMode: 'per_user',
34
+ rateLimitPerMinute: 60,
35
+ enabled: true,
36
+ periodType: 'monthly',
37
+ timezone: 'UTC',
38
+ currency: 'USD',
39
+ rejectUnpricedModel: true,
40
+ missingUsageBehavior: 'use_reserved',
41
+ contextOverflowBehavior: behavior,
42
+ };
43
+ if (!key) return record;
44
+ return record[key];
45
+ },
46
+ }),
25
47
  };
26
48
  }
27
49
  return { findOne: vi.fn() };
@@ -67,6 +89,33 @@ describe('direct LLM context preparation', () => {
67
89
  findOne: vi.fn().mockResolvedValue({ get: (key: string) => (key === 'contextWindow' ? 120 : 40) }),
68
90
  } as never;
69
91
  }
92
+ if (name === 'aiApiGroupMembers') {
93
+ return { findOne: vi.fn().mockResolvedValue(null) } as never;
94
+ }
95
+ if (name === 'aiApiUsageGroups') {
96
+ return {
97
+ findOne: vi.fn().mockResolvedValue({
98
+ get: (key?: string) => {
99
+ const record: Record<string, unknown> = {
100
+ id: 1,
101
+ name: 'Default',
102
+ isDefault: true,
103
+ quotaMode: 'per_user',
104
+ rateLimitPerMinute: 60,
105
+ enabled: true,
106
+ periodType: 'monthly',
107
+ timezone: 'UTC',
108
+ currency: 'USD',
109
+ rejectUnpricedModel: true,
110
+ missingUsageBehavior: 'use_reserved',
111
+ contextOverflowBehavior: 'reject',
112
+ };
113
+ if (!key) return record;
114
+ return record[key];
115
+ },
116
+ }),
117
+ } as never;
118
+ }
70
119
  return { findOne: vi.fn().mockResolvedValue(null) } as never;
71
120
  });
72
121
 
@@ -188,6 +237,75 @@ describe('direct LLM context preparation', () => {
188
237
  ),
189
238
  ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
190
239
  });
240
+
241
+ it('prepends the initial system prompt before the client system prompt', async () => {
242
+ const clientMessages: OpenAIMessage[] = [
243
+ { role: 'system', content: 'Client system prompt' },
244
+ { role: 'user', content: 'hello' },
245
+ ];
246
+ const prepared = await prepareDirectLlmContext(
247
+ context({ metadata: { contextWindow: 120, maxCompletionTokens: 40, systemPrompt: 'Initial prompt' } }),
248
+ request(clientMessages),
249
+ );
250
+
251
+ expect(prepared.messages).toEqual([
252
+ { role: 'system', content: 'Initial prompt' },
253
+ { role: 'system', content: 'Client system prompt' },
254
+ { role: 'user', content: 'hello' },
255
+ ]);
256
+ expect(prepared.truncated).toBe(false);
257
+ expect(clientMessages).toHaveLength(2);
258
+ });
259
+
260
+ it('uses the initial system prompt as the only system message when the client sends none', async () => {
261
+ const prepared = await prepareDirectLlmContext(
262
+ context({ metadata: { contextWindow: 120, maxCompletionTokens: 40, systemPrompt: 'Initial prompt' } }),
263
+ request([{ role: 'user', content: 'hello' }]),
264
+ );
265
+
266
+ expect(prepared.messages).toEqual([
267
+ { role: 'system', content: 'Initial prompt' },
268
+ { role: 'user', content: 'hello' },
269
+ ]);
270
+ });
271
+
272
+ it('ignores a blank initial system prompt', async () => {
273
+ const messages = [{ role: 'user', content: 'hello' }];
274
+ const prepared = await prepareDirectLlmContext(
275
+ context({ metadata: { contextWindow: 120, maxCompletionTokens: 40, systemPrompt: ' ' } }),
276
+ request(messages),
277
+ );
278
+
279
+ expect(prepared.messages).toBe(messages);
280
+ });
281
+
282
+ it('counts the initial system prompt toward the input budget', async () => {
283
+ await expect(
284
+ prepareDirectLlmContext(
285
+ context({ metadata: { contextWindow: 120, maxCompletionTokens: 40, systemPrompt: 'x'.repeat(400) } }),
286
+ request([{ role: 'user', content: 'hello' }]),
287
+ ),
288
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
289
+ });
290
+
291
+ it('keeps the initial system prompt when truncating oldest turns', async () => {
292
+ const messages: OpenAIMessage[] = [
293
+ { role: 'user', content: 'first '.repeat(30) },
294
+ { role: 'assistant', content: 'first answer '.repeat(20) },
295
+ { role: 'user', content: 'latest question' },
296
+ ];
297
+
298
+ const prepared = await prepareDirectLlmContext(
299
+ context({
300
+ behavior: 'truncate',
301
+ metadata: { contextWindow: 120, maxCompletionTokens: 40, systemPrompt: 'Initial prompt' },
302
+ }),
303
+ request(messages),
304
+ );
305
+
306
+ expect(prepared.truncated).toBe(true);
307
+ expect(prepared.messages).toEqual([{ role: 'system', content: 'Initial prompt' }, messages[2]]);
308
+ });
191
309
  });
192
310
 
193
311
  describe('image dimension parsing', () => {
@@ -0,0 +1,225 @@
1
+ import dns from 'dns';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ FileProcessorError,
5
+ fetchFileAsBase64,
6
+ httpFileUrlFetcher,
7
+ isBlockedAddress,
8
+ type FileProcessorContext,
9
+ } from '../services/file-processor';
10
+
11
+ const PUBLIC_ADDRESS = { address: '93.184.216.34', family: 4 };
12
+
13
+ function mockDnsLookup(...results: Array<Array<{ address: string; family: number }>>) {
14
+ const spy = vi.spyOn(dns.promises, 'lookup');
15
+ for (const addresses of results) {
16
+ // The overloaded lookup signatures defeat vitest's inferred mock value type.
17
+ spy.mockResolvedValueOnce(addresses as never);
18
+ }
19
+ spy.mockRejectedValue(new Error('unexpected dns lookup'));
20
+ return spy;
21
+ }
22
+
23
+ function mockFetch(...responses: Response[]) {
24
+ const fetchMock = vi.fn();
25
+ for (const response of responses) {
26
+ fetchMock.mockResolvedValueOnce(response);
27
+ }
28
+ fetchMock.mockRejectedValue(new Error('unexpected fetch'));
29
+ vi.stubGlobal('fetch', fetchMock);
30
+ return fetchMock;
31
+ }
32
+
33
+ function okResponse(body: string, contentType = 'text/plain') {
34
+ return new Response(body, {
35
+ status: 200,
36
+ headers: { 'content-type': contentType, 'content-length': String(Buffer.byteLength(body)) },
37
+ });
38
+ }
39
+
40
+ function redirectResponse(location: string, status = 302) {
41
+ return new Response(null, { status, headers: { location } });
42
+ }
43
+
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ vi.unstubAllGlobals();
47
+ });
48
+
49
+ describe('isBlockedAddress', () => {
50
+ it('blocks private, loopback, link-local and reserved IPv4 ranges', () => {
51
+ expect(isBlockedAddress('0.0.0.0')).toBe(true);
52
+ expect(isBlockedAddress('10.0.0.1')).toBe(true);
53
+ expect(isBlockedAddress('100.64.0.1')).toBe(true);
54
+ expect(isBlockedAddress('100.127.255.255')).toBe(true);
55
+ expect(isBlockedAddress('127.0.0.1')).toBe(true);
56
+ expect(isBlockedAddress('169.254.169.254')).toBe(true);
57
+ expect(isBlockedAddress('172.16.0.1')).toBe(true);
58
+ expect(isBlockedAddress('172.31.255.255')).toBe(true);
59
+ expect(isBlockedAddress('192.0.0.1')).toBe(true);
60
+ expect(isBlockedAddress('192.168.0.10')).toBe(true);
61
+ expect(isBlockedAddress('198.18.0.1')).toBe(true);
62
+ expect(isBlockedAddress('198.19.0.1')).toBe(true);
63
+ expect(isBlockedAddress('224.0.0.1')).toBe(true);
64
+ expect(isBlockedAddress('255.255.255.255')).toBe(true);
65
+ });
66
+
67
+ it('allows public IPv4 addresses at the range boundaries', () => {
68
+ expect(isBlockedAddress('8.8.8.8')).toBe(false);
69
+ expect(isBlockedAddress('100.63.255.255')).toBe(false);
70
+ expect(isBlockedAddress('100.128.0.0')).toBe(false);
71
+ expect(isBlockedAddress('172.15.255.255')).toBe(false);
72
+ expect(isBlockedAddress('172.32.0.0')).toBe(false);
73
+ expect(isBlockedAddress('192.167.0.1')).toBe(false);
74
+ expect(isBlockedAddress('198.17.255.255')).toBe(false);
75
+ expect(isBlockedAddress('198.20.0.0')).toBe(false);
76
+ });
77
+
78
+ it('blocks unspecified, loopback, ULA, link-local and multicast IPv6 addresses', () => {
79
+ expect(isBlockedAddress('::')).toBe(true);
80
+ expect(isBlockedAddress('::1')).toBe(true);
81
+ expect(isBlockedAddress('fc00::1')).toBe(true);
82
+ expect(isBlockedAddress('fd12:3456::1')).toBe(true);
83
+ expect(isBlockedAddress('fe80::1')).toBe(true);
84
+ expect(isBlockedAddress('febf::1')).toBe(true);
85
+ expect(isBlockedAddress('ff02::1')).toBe(true);
86
+ });
87
+
88
+ it('blocks IPv6 forms that embed blocked IPv4 addresses', () => {
89
+ expect(isBlockedAddress('::ffff:127.0.0.1')).toBe(true);
90
+ expect(isBlockedAddress('::ffff:10.0.0.1')).toBe(true);
91
+ expect(isBlockedAddress('::ffff:169.254.169.254')).toBe(true);
92
+ expect(isBlockedAddress('::10.0.0.1')).toBe(true);
93
+ expect(isBlockedAddress('64:ff9b::7f00:1')).toBe(true);
94
+ });
95
+
96
+ it('allows public IPv6 addresses', () => {
97
+ expect(isBlockedAddress('2001:4860:4860::8888')).toBe(false);
98
+ expect(isBlockedAddress('2606:4700:4700::1111')).toBe(false);
99
+ expect(isBlockedAddress('::ffff:8.8.8.8')).toBe(false);
100
+ expect(isBlockedAddress('64:ff9b::808:808')).toBe(false);
101
+ });
102
+
103
+ it('blocks unparseable input defensively', () => {
104
+ expect(isBlockedAddress('')).toBe(true);
105
+ expect(isBlockedAddress('not-an-ip')).toBe(true);
106
+ expect(isBlockedAddress('1::2::3')).toBe(true);
107
+ expect(isBlockedAddress('1.2.3')).toBe(true);
108
+ expect(isBlockedAddress('1.2.3.256')).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('fetchFileAsBase64 SSRF protection', () => {
113
+ it('blocks hosts that resolve to private addresses before fetching', async () => {
114
+ const lookupSpy = mockDnsLookup([{ address: '169.254.169.254', family: 4 }]);
115
+ const fetchMock = mockFetch();
116
+ await expect(fetchFileAsBase64('https://evil.example.com/latest/meta-data')).rejects.toMatchObject({
117
+ code: 'blocked_host',
118
+ });
119
+ expect(lookupSpy).toHaveBeenCalledWith('evil.example.com', { all: true });
120
+ expect(fetchMock).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('blocks when any resolved address is private', async () => {
124
+ mockDnsLookup([PUBLIC_ADDRESS, { address: '10.0.0.1', family: 4 }]);
125
+ const fetchMock = mockFetch();
126
+ await expect(fetchFileAsBase64('https://dual.example.com/file')).rejects.toMatchObject({
127
+ code: 'blocked_host',
128
+ });
129
+ expect(fetchMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('fetches files from public hosts', async () => {
133
+ mockDnsLookup([PUBLIC_ADDRESS]);
134
+ const fetchMock = mockFetch(okResponse('hello world'));
135
+ const result = await fetchFileAsBase64('https://example.com/doc.txt');
136
+ expect(fetchMock).toHaveBeenCalledWith(
137
+ 'https://example.com/doc.txt',
138
+ expect.objectContaining({ redirect: 'manual' }),
139
+ );
140
+ expect(result.mimeType).toBe('text/plain');
141
+ expect(result.filename).toBe('doc.txt');
142
+ expect(result.fileData).toBe(`data:text/plain;base64,${Buffer.from('hello world').toString('base64')}`);
143
+ });
144
+
145
+ it('blocks redirects that point at private hosts', async () => {
146
+ const lookupSpy = mockDnsLookup([PUBLIC_ADDRESS], [{ address: '127.0.0.1', family: 4 }]);
147
+ const fetchMock = mockFetch(redirectResponse('http://internal.example/secret'));
148
+ await expect(fetchFileAsBase64('https://example.com/file')).rejects.toMatchObject({ code: 'blocked_host' });
149
+ expect(fetchMock).toHaveBeenCalledTimes(1);
150
+ expect(lookupSpy).toHaveBeenCalledTimes(2);
151
+ });
152
+
153
+ it('follows redirects to allowed public hosts', async () => {
154
+ mockDnsLookup([PUBLIC_ADDRESS], [{ address: '104.16.0.1', family: 4 }]);
155
+ const fetchMock = mockFetch(redirectResponse('https://cdn.example.com/doc.txt', 301), okResponse('payload'));
156
+ const result = await fetchFileAsBase64('https://example.com/doc.txt');
157
+ expect(fetchMock).toHaveBeenCalledTimes(2);
158
+ expect(result.fileData).toBe(`data:text/plain;base64,${Buffer.from('payload').toString('base64')}`);
159
+ expect(result.filename).toBe('doc.txt');
160
+ });
161
+
162
+ it('resolves relative redirect locations against the current URL', async () => {
163
+ mockDnsLookup([PUBLIC_ADDRESS], [PUBLIC_ADDRESS]);
164
+ const fetchMock = mockFetch(redirectResponse('/moved/doc.txt'), okResponse('data'));
165
+ await fetchFileAsBase64('https://example.com/doc.txt');
166
+ expect(fetchMock).toHaveBeenLastCalledWith(
167
+ 'https://example.com/moved/doc.txt',
168
+ expect.objectContaining({ redirect: 'manual' }),
169
+ );
170
+ });
171
+
172
+ it('blocks literal private IP hosts without DNS lookup or fetch', async () => {
173
+ const lookupSpy = vi.spyOn(dns.promises, 'lookup');
174
+ const fetchMock = mockFetch();
175
+ await expect(fetchFileAsBase64('http://192.168.0.10/x')).rejects.toMatchObject({ code: 'blocked_host' });
176
+ expect(lookupSpy).not.toHaveBeenCalled();
177
+ expect(fetchMock).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('blocks bracketed IPv6 loopback literals', async () => {
181
+ const fetchMock = mockFetch();
182
+ await expect(fetchFileAsBase64('http://[::1]/x')).rejects.toMatchObject({ code: 'blocked_host' });
183
+ expect(fetchMock).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('stops after the redirect limit', async () => {
187
+ const lookupSpy = vi.spyOn(dns.promises, 'lookup');
188
+ lookupSpy.mockResolvedValue([PUBLIC_ADDRESS] as never);
189
+ const fetchMock = vi.fn().mockResolvedValue(redirectResponse('https://example.com/loop'));
190
+ vi.stubGlobal('fetch', fetchMock);
191
+ await expect(fetchFileAsBase64('https://example.com/loop', { maxRedirects: 3 })).rejects.toMatchObject({
192
+ code: 'too_many_redirects',
193
+ });
194
+ expect(fetchMock).toHaveBeenCalledTimes(4);
195
+ });
196
+
197
+ it('reports DNS failures as fetch_failed', async () => {
198
+ const lookupSpy = vi.spyOn(dns.promises, 'lookup');
199
+ lookupSpy.mockRejectedValue(new Error('getaddrinfo ENOTFOUND'));
200
+ const fetchMock = mockFetch();
201
+ await expect(fetchFileAsBase64('https://no-such-host.example/x')).rejects.toMatchObject({
202
+ code: 'fetch_failed',
203
+ });
204
+ expect(fetchMock).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it('keeps the existing URL validation errors', async () => {
208
+ await expect(fetchFileAsBase64('not a url')).rejects.toMatchObject({ code: 'invalid_url' });
209
+ await expect(fetchFileAsBase64('ftp://example.com/file')).rejects.toMatchObject({
210
+ code: 'unsupported_protocol',
211
+ });
212
+ });
213
+
214
+ it('surfaces blocked hosts through the httpFileUrlFetcher processor', async () => {
215
+ const fetchMock = mockFetch();
216
+ const context = { ctx: {} } as unknown as FileProcessorContext;
217
+ await expect(
218
+ httpFileUrlFetcher.process({ type: 'file_url', file_url: { url: 'http://10.0.0.5/data.pdf' } }, context),
219
+ ).rejects.toBeInstanceOf(FileProcessorError);
220
+ await expect(
221
+ httpFileUrlFetcher.process({ type: 'file_url', file_url: { url: 'http://10.0.0.5/data.pdf' } }, context),
222
+ ).rejects.toMatchObject({ code: 'blocked_host' });
223
+ expect(fetchMock).not.toHaveBeenCalled();
224
+ });
225
+ });
@@ -21,7 +21,7 @@ function permissionLookupFailureContext() {
21
21
  getRepository: (name: string) => {
22
22
  if (name === 'aiApiConfig') return { findOne: vi.fn(async () => null) };
23
23
  if (name === 'llmServices') return { find: vi.fn(async () => []) };
24
- if (name === 'aiApiUserPermissions') {
24
+ if (name === 'aiApiGroupMembers') {
25
25
  return { findOne: vi.fn(async () => Promise.reject(new Error('permission database unavailable'))) };
26
26
  }
27
27
  return { find: vi.fn(async () => []) };