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
@@ -63,6 +63,7 @@
63
63
  "Input tokens": "Input token",
64
64
  "Output tokens": "Output token",
65
65
  "Total tokens": "Tổng token",
66
+ "Prompt cache tokens": "Prompt cache token",
66
67
  "Cost": "Chi phí",
67
68
  "Cost status": "Trạng thái chi phí",
68
69
  "Request ID": "Request ID",
@@ -98,6 +99,8 @@
98
99
  "Leave empty to not override": "Để trống nếu không override",
99
100
  "Total input + output token capacity reported to clients.": "Tổng dung lượng token (input + output) trả về cho client.",
100
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.",
101
104
  "AI API": "AI API",
102
105
  "Allow this role to use the AI API": "Cho phép vai trò này sử dụng AI API",
103
106
  "Allow all AI Employees": "Cho phép tất cả AI Employee",
@@ -105,15 +108,31 @@
105
108
  "Select allowed AI Employees": "Chọn AI Employee được phép",
106
109
  "Max request body size (MB)": "Giới hạn kích thước request body (MB)",
107
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.",
108
- "User LLM permissions": "Phân quyền LLM theo người dùng",
109
- "Add permission": "Thêm phân quyền",
110
- "Edit permission": "Sửa phân quyền",
111
- "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",
112
129
  "Allowed LLM services": "Dịch vụ LLM được phép",
113
130
  "Allow all models": "Cho phép tất cả model",
114
131
  "Allowed models": "Model được phép",
115
- "No service allowed": "Không được phép dịch vụ nào",
116
- "All models of allowed services": "Tất cả model của các dịch vụ được phép",
117
- "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.",
118
- "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."
119
138
  }
@@ -63,6 +63,7 @@
63
63
  "Input tokens": "输入令牌",
64
64
  "Output tokens": "输出令牌",
65
65
  "Total tokens": "总令牌",
66
+ "Prompt cache tokens": "提示缓存令牌",
66
67
  "Cost": "费用",
67
68
  "Cost status": "费用状态",
68
69
  "Request ID": "请求 ID",
@@ -98,6 +99,8 @@
98
99
  "Leave empty to not override": "留空则不覆盖",
99
100
  "Total input + output token capacity reported to clients.": "返回给客户端的输入+输出 token 总容量。",
100
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 提示词。",
101
104
  "AI API": "AI API",
102
105
  "Allow this role to use the AI API": "允许此角色使用 AI API",
103
106
  "Allow all AI Employees": "允许所有 AI 员工",
@@ -105,15 +108,31 @@
105
108
  "Select allowed AI Employees": "选择允许的 AI 员工",
106
109
  "Max request body size (MB)": "请求体大小上限(MB)",
107
110
  "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "调高此值以接收内联 base64 图片。base64 编码会使体积增加约 33%。",
108
- "User LLM permissions": "用户 LLM 权限",
109
- "Add permission": "添加权限",
110
- "Edit permission": "编辑权限",
111
- "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": "默认",
112
129
  "Allowed LLM services": "允许的 LLM 服务",
113
130
  "Allow all models": "允许所有模型",
114
131
  "Allowed models": "允许的模型",
115
- "No service allowed": "未允许任何服务",
116
- "All models of allowed services": "允许服务下的所有模型",
117
- "Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.": "此处列出的用户仅能使用下方所选的服务;没有记录的用户按通用配置处理。",
118
- "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.": "不属于其他分组的用户会自动使用此默认分组,无需手动添加成员。"
119
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');
@@ -1,6 +1,11 @@
1
1
  import type { Context } from '@nocobase/actions';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
- import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
3
+ import {
4
+ DirectLlmContextError,
5
+ prepareDirectLlmContext,
6
+ type OpenAIMessage,
7
+ parseImageDimensions,
8
+ } from '../utils/direct-llm-context';
4
9
 
5
10
  function context({ behavior = 'reject', metadata = { contextWindow: 120, maxCompletionTokens: 40 } } = {}): Context {
6
11
  return {
@@ -12,11 +17,33 @@ function context({ behavior = 'reject', metadata = { contextWindow: 120, maxComp
12
17
  findOne: vi.fn().mockResolvedValue({ get: (key: string) => metadata[key as keyof typeof metadata] }),
13
18
  };
14
19
  }
15
- if (name === 'aiApiUserQuotaPolicies') {
20
+ if (name === 'aiApiGroupMembers') {
16
21
  return {
17
- findOne: vi
18
- .fn()
19
- .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
+ }),
20
47
  };
21
48
  }
22
49
  return { findOne: vi.fn() };
@@ -34,6 +61,25 @@ function request(messages: OpenAIMessage[], tools?: unknown) {
34
61
  };
35
62
  }
36
63
 
64
+ // A 1x1 PNG encoded as a base64 data URL.
65
+ const ONE_PIXEL_PNG =
66
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
67
+
68
+ // A tiny valid base64 PDF payload (PDF header + minimal content).
69
+ const TINY_PDF_BASE64 = 'data:application/pdf;base64,JVBERi0xLjAKPDwKPiEKZW5kb2JqCmVuZG9iagpl';
70
+
71
+ // A PNG header with 1280x720 dimensions. The pixel data is truncated/invalid,
72
+ // but the header is valid enough for dimension parsing to succeed.
73
+ const LARGE_PNG_HEADER_BASE64 =
74
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABQAAAALQCAYAAADPfd1WAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoSAA';
75
+
76
+ // The fixed conservative estimate used for http(s) image URLs.
77
+ const VISION_HTTP_URL_ESTIMATE = 1024;
78
+
79
+ function largeContext(): Context {
80
+ return context({ metadata: { contextWindow: 2000, maxCompletionTokens: 40 } });
81
+ }
82
+
37
83
  describe('direct LLM context preparation', () => {
38
84
  it('uses reject when the user has no enabled policy', async () => {
39
85
  const ctx = context();
@@ -43,6 +89,33 @@ describe('direct LLM context preparation', () => {
43
89
  findOne: vi.fn().mockResolvedValue({ get: (key: string) => (key === 'contextWindow' ? 120 : 40) }),
44
90
  } as never;
45
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
+ }
46
119
  return { findOne: vi.fn().mockResolvedValue(null) } as never;
47
120
  });
48
121
 
@@ -100,15 +173,57 @@ describe('direct LLM context preparation', () => {
100
173
  ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
101
174
  });
102
175
 
103
- it('rejects image content until a model-specific estimator is available', async () => {
176
+ it('estimates vision tokens for a base64 image_url and allows small payloads', async () => {
177
+ const prepared = await prepareDirectLlmContext(
178
+ largeContext(),
179
+ request([{ role: 'user', content: [{ type: 'image_url', image_url: { url: ONE_PIXEL_PNG } }] }]),
180
+ );
181
+
182
+ expect(prepared.estimatedInputTokens).toBeGreaterThan(0);
183
+ expect(prepared.truncated).toBe(false);
184
+ });
185
+
186
+ it('estimates a fixed conservative token count for http(s) image_url URLs', async () => {
187
+ const prepared = await prepareDirectLlmContext(
188
+ largeContext(),
189
+ request([
190
+ { role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] },
191
+ ]),
192
+ );
193
+
194
+ expect(prepared.estimatedInputTokens).toBeGreaterThanOrEqual(VISION_HTTP_URL_ESTIMATE);
195
+ expect(prepared.truncated).toBe(false);
196
+ });
197
+
198
+ it('rejects a base64 image that exceeds the input budget', async () => {
199
+ // Large header claims 1000x1000, so vision estimate is 85 + 4 * 170 = 765 tokens,
200
+ // which easily exceeds the 80 token budget of the default test context.
201
+ await expect(
202
+ prepareDirectLlmContext(
203
+ context(),
204
+ request([{ role: 'user', content: [{ type: 'image_url', image_url: { url: LARGE_PNG_HEADER_BASE64 } }] }]),
205
+ ),
206
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
207
+ });
208
+
209
+ it('estimates file block tokens from decoded base64 size', async () => {
210
+ const prepared = await prepareDirectLlmContext(
211
+ context(),
212
+ request([{ role: 'user', content: [{ type: 'file', file: { file_data: TINY_PDF_BASE64, filename: 'x.pdf' } }] }]),
213
+ );
214
+
215
+ expect(prepared.estimatedInputTokens).toBeGreaterThan(0);
216
+ expect(prepared.truncated).toBe(false);
217
+ });
218
+
219
+ it('rejects a base64 file that exceeds the input budget', async () => {
220
+ const largeBase64 = `data:application/pdf;base64,${Buffer.alloc(100_000).toString('base64')}`;
104
221
  await expect(
105
222
  prepareDirectLlmContext(
106
223
  context(),
107
- request([
108
- { role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.test/image.png' } }] },
109
- ]),
224
+ request([{ role: 'user', content: [{ type: 'file', file: { file_data: largeBase64, filename: 'x.pdf' } }] }]),
110
225
  ),
111
- ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_estimation_unsupported' });
226
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
112
227
  });
113
228
 
114
229
  it('counts tool definitions as fixed input overhead', async () => {
@@ -122,4 +237,88 @@ describe('direct LLM context preparation', () => {
122
237
  ),
123
238
  ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
124
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
+ });
309
+ });
310
+
311
+ describe('image dimension parsing', () => {
312
+ it('parses PNG dimensions', () => {
313
+ // The shared 1x1 test PNG is a valid PNG with dimensions 1x1.
314
+ const base64 = ONE_PIXEL_PNG.split(',')[1];
315
+ const png = Buffer.from(base64, 'base64');
316
+ expect(parseImageDimensions(png)).toEqual({ width: 1, height: 1 });
317
+ });
318
+
319
+ it('parses JPEG dimensions without reading past width/height', () => {
320
+ // Minimal JPEG SOF0 segment: height 1024, width 1024.
321
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x04, 0x00, 0x04, 0x00, 0x01, 0x22, 0x00]);
322
+ expect(parseImageDimensions(jpeg)).toEqual({ width: 1024, height: 1024 });
323
+ });
125
324
  });