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
@@ -10,60 +10,61 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { beforeEach, describe, expect, it, vi } from 'vitest';
12
12
  import { PluginAiApiServer } from '../plugin';
13
- import { invalidateUserPermissionCache, resolveUserAccessScope } from '../utils/user-permissions';
13
+ import { invalidateGroupAccessCache, resolveUserAccessScope } from '../utils/user-permissions';
14
14
 
15
15
  /**
16
- * Covers the cross-node half of permission revocation.
16
+ * Covers the cross-node half of group-access invalidation.
17
17
  *
18
18
  * syncMessageManager hardcodes `skipSelf: true` (sync-message-manager.ts:59,73), so the node
19
19
  * that writes the change never receives its own broadcast. That makes two things load-bearing
20
20
  * and easy to regress: the writer must invalidate its own cache locally, and every other node
21
21
  * must invalidate on receipt. Neither is observable from a single-node test of the cache alone.
22
22
  */
23
- function mockContext(userId: number, row: unknown) {
24
- const findOne = vi.fn(async () => row);
23
+ function row(values: Record<string, unknown>) {
24
+ return { get: (key?: string) => (key === undefined ? values : values[key]) };
25
+ }
26
+
27
+ function mockContext(userId: number, groupValues: Record<string, unknown>) {
28
+ const group = row({ allowedLlmServices: [], allowAllModels: true, allowedModels: [], ...groupValues });
29
+ const findOne = vi.fn(async () => row({ group }));
25
30
  const ctx = {
26
31
  state: { currentUser: { id: userId } },
27
- db: { getRepository: () => ({ findOne }) },
32
+ db: {
33
+ getRepository: (name: string) =>
34
+ name === 'aiApiGroupMembers' ? { findOne } : { findOne: async () => null, create: vi.fn() },
35
+ },
28
36
  app: { name: 'main' },
29
37
  log: { warn: vi.fn(), error: vi.fn() },
30
38
  } as unknown as Context;
31
39
  return { ctx, findOne };
32
40
  }
33
41
 
34
- function row(values: Record<string, unknown>) {
35
- return { get: (key: string) => values[key] };
36
- }
37
-
38
42
  beforeEach(() => {
39
- invalidateUserPermissionCache();
43
+ invalidateGroupAccessCache();
40
44
  });
41
45
 
42
- describe('cross-node permission invalidation', () => {
46
+ describe('cross-node group access invalidation', () => {
43
47
  it("drops the receiving node's cached scope", async () => {
44
48
  const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
45
- const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
49
+ const { ctx } = mockContext(1, { id: 5, allowedLlmServices: ['openai'] });
46
50
 
47
- await resolveUserAccessScope(ctx);
48
- await resolveUserAccessScope(ctx);
49
- expect(findOne).toHaveBeenCalledTimes(1);
51
+ const before = await resolveUserAccessScope(ctx);
52
+ expect(await resolveUserAccessScope(ctx)).toBe(before);
50
53
 
51
- await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 1 });
54
+ await plugin.handleSyncMessage({ type: 'invalidateGroupAccess', groupId: 5 });
52
55
 
53
- await resolveUserAccessScope(ctx);
54
- expect(findOne).toHaveBeenCalledTimes(2);
56
+ expect(await resolveUserAccessScope(ctx)).not.toBe(before);
55
57
  });
56
58
 
57
- it('ignores unrelated message types and other users', async () => {
59
+ it('ignores unrelated message types and other groups', async () => {
58
60
  const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
59
- const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
60
- await resolveUserAccessScope(ctx);
61
+ const { ctx } = mockContext(1, { id: 5, allowedLlmServices: ['openai'] });
62
+ const before = await resolveUserAccessScope(ctx);
61
63
 
62
- await plugin.handleSyncMessage({ type: 'somethingElse', userId: 1 });
63
- await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 2 });
64
+ await plugin.handleSyncMessage({ type: 'somethingElse', groupId: 5 });
65
+ await plugin.handleSyncMessage({ type: 'invalidateGroupAccess', groupId: 6 });
64
66
 
65
- await resolveUserAccessScope(ctx);
66
- expect(findOne).toHaveBeenCalledTimes(1);
67
+ expect(await resolveUserAccessScope(ctx)).toBe(before);
67
68
  });
68
69
 
69
70
  it('tolerates a malformed message instead of throwing into the subscriber', async () => {
@@ -77,18 +78,16 @@ describe('cross-node permission invalidation', () => {
77
78
  const sendSyncMessage = vi.fn(async () => undefined);
78
79
  Object.assign(plugin, { sendSyncMessage });
79
80
 
80
- const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
81
- await resolveUserAccessScope(ctx);
82
- expect(findOne).toHaveBeenCalledTimes(1);
81
+ const { ctx } = mockContext(1, { id: 5, allowedLlmServices: ['openai'] });
82
+ const before = await resolveUserAccessScope(ctx);
83
83
 
84
- // revokeUserPermissions is private; reach it the way the db hook does.
85
- (plugin as unknown as { revokeUserPermissions: (id: unknown, tx?: unknown) => void }).revokeUserPermissions(1);
84
+ // invalidateGroupAccess is private; reach it the way the db hook does.
85
+ (plugin as unknown as { invalidateGroupAccess: (id: unknown, tx?: unknown) => void }).invalidateGroupAccess(5);
86
86
 
87
87
  // Local cache cleared without any message coming back to us.
88
- await resolveUserAccessScope(ctx);
89
- expect(findOne).toHaveBeenCalledTimes(2);
88
+ expect(await resolveUserAccessScope(ctx)).not.toBe(before);
90
89
  expect(sendSyncMessage).toHaveBeenCalledWith(
91
- { type: 'invalidateUserPermissions', userId: 1 },
90
+ { type: 'invalidateGroupAccess', groupId: 5 },
92
91
  { transaction: undefined },
93
92
  );
94
93
  });
@@ -99,11 +98,11 @@ describe('cross-node permission invalidation', () => {
99
98
  Object.assign(plugin, { sendSyncMessage });
100
99
  const transaction = { id: 'tx-1' };
101
100
 
102
- (plugin as unknown as { revokeUserPermissions: (id: unknown, tx?: unknown) => void }).revokeUserPermissions(
101
+ (plugin as unknown as { invalidateGroupAccess: (id: unknown, tx?: unknown) => void }).invalidateGroupAccess(
103
102
  7,
104
103
  transaction,
105
104
  );
106
105
 
107
- expect(sendSyncMessage).toHaveBeenCalledWith({ type: 'invalidateUserPermissions', userId: 7 }, { transaction });
106
+ expect(sendSyncMessage).toHaveBeenCalledWith({ type: 'invalidateGroupAccess', groupId: 7 }, { transaction });
108
107
  });
109
108
  });
@@ -0,0 +1,160 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { createMockDatabase, type Database } from '@nocobase/database';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import { resolveUserGroup, getDefaultGroup } from '../quota-groups';
5
+
6
+ describe('AI API usage groups', () => {
7
+ let db: Database;
8
+
9
+ beforeEach(async () => {
10
+ db = await createMockDatabase();
11
+ db.collection({
12
+ name: 'aiApiUsageGroups',
13
+ fields: [
14
+ { name: 'name', type: 'string' },
15
+ { name: 'isDefault', type: 'boolean' },
16
+ { name: 'quotaMode', type: 'string' },
17
+ { name: 'rateLimitPerMinute', type: 'integer' },
18
+ { name: 'enabled', type: 'boolean' },
19
+ { name: 'periodType', type: 'string' },
20
+ { name: 'timezone', type: 'string' },
21
+ { name: 'requestLimit', type: 'bigInt' },
22
+ { name: 'totalTokenLimit', type: 'bigInt' },
23
+ { name: 'costLimit', type: 'decimal', precision: 20, scale: 8 },
24
+ { name: 'currency', type: 'string' },
25
+ { name: 'rejectUnpricedModel', type: 'boolean' },
26
+ { name: 'missingUsageBehavior', type: 'string' },
27
+ { name: 'contextOverflowBehavior', type: 'string' },
28
+ { name: 'allowedLlmServices', type: 'json' },
29
+ { name: 'allowAllModels', type: 'boolean' },
30
+ { name: 'allowedModels', type: 'json' },
31
+ ],
32
+ });
33
+ db.collection({
34
+ name: 'aiApiGroupMembers',
35
+ fields: [
36
+ { name: 'groupId', type: 'bigInt' },
37
+ {
38
+ name: 'group',
39
+ type: 'belongsTo',
40
+ target: 'aiApiUsageGroups',
41
+ targetKey: 'id',
42
+ foreignKey: 'groupId',
43
+ },
44
+ { name: 'userId', type: 'bigInt' },
45
+ ],
46
+ indexes: [{ fields: ['userId'], unique: true }],
47
+ });
48
+ await db.sync({ force: true });
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await db.close();
53
+ });
54
+
55
+ function context(): Context {
56
+ return { db, request: {}, state: {} } as unknown as Context;
57
+ }
58
+
59
+ it('creates the default group lazily with open model access', async () => {
60
+ const group = await getDefaultGroup(context());
61
+ expect(group.name).toBe('Default');
62
+ expect(group.isDefault).toBe(true);
63
+ expect(group.quotaMode).toBe('per_user');
64
+ // The default group must never lock everyone out: empty lists mean "no narrowing".
65
+ expect(group.allowedLlmServices).toEqual([]);
66
+ expect(group.allowAllModels).toBe(true);
67
+ expect(group.allowedModels).toEqual([]);
68
+
69
+ const second = await getDefaultGroup(context());
70
+ expect(second.id).toBe(group.id);
71
+ });
72
+
73
+ it('resolves an unassigned user to the default group', async () => {
74
+ const group = await resolveUserGroup(context(), 99);
75
+ expect(group.name).toBe('Default');
76
+ expect(group.isDefault).toBe(true);
77
+ });
78
+
79
+ it('resolves an assigned user to their explicit group', async () => {
80
+ const custom = await db.getRepository('aiApiUsageGroups').create({
81
+ values: {
82
+ name: 'Pro',
83
+ isDefault: false,
84
+ quotaMode: 'share',
85
+ rateLimitPerMinute: 120,
86
+ enabled: true,
87
+ periodType: 'monthly',
88
+ timezone: 'UTC',
89
+ currency: 'USD',
90
+ rejectUnpricedModel: true,
91
+ missingUsageBehavior: 'use_reserved',
92
+ contextOverflowBehavior: 'reject',
93
+ },
94
+ });
95
+ await db.getRepository('aiApiGroupMembers').create({
96
+ values: { groupId: custom.get('id'), userId: 42 },
97
+ });
98
+
99
+ const group = await resolveUserGroup(context(), 42);
100
+ expect(group.name).toBe('Pro');
101
+ expect(group.quotaMode).toBe('share');
102
+ });
103
+
104
+ it('carries the model access fields through group resolution', async () => {
105
+ const custom = await db.getRepository('aiApiUsageGroups').create({
106
+ values: {
107
+ name: 'Restricted',
108
+ isDefault: false,
109
+ quotaMode: 'per_user',
110
+ rateLimitPerMinute: 60,
111
+ enabled: false,
112
+ periodType: 'monthly',
113
+ timezone: 'UTC',
114
+ currency: 'USD',
115
+ rejectUnpricedModel: true,
116
+ missingUsageBehavior: 'use_reserved',
117
+ contextOverflowBehavior: 'reject',
118
+ allowedLlmServices: ['svc'],
119
+ allowAllModels: false,
120
+ allowedModels: ['svc/model-a'],
121
+ },
122
+ });
123
+ await db.getRepository('aiApiGroupMembers').create({
124
+ values: { groupId: custom.get('id'), userId: 43 },
125
+ });
126
+
127
+ const group = await resolveUserGroup(context(), 43);
128
+ expect(group.allowedLlmServices).toEqual(['svc']);
129
+ expect(group.allowAllModels).toBe(false);
130
+ expect(group.allowedModels).toEqual(['svc/model-a']);
131
+ });
132
+
133
+ it('drops non-string entries from the access lists', async () => {
134
+ const custom = await db.getRepository('aiApiUsageGroups').create({
135
+ values: {
136
+ name: 'Messy',
137
+ isDefault: false,
138
+ quotaMode: 'per_user',
139
+ rateLimitPerMinute: 60,
140
+ enabled: false,
141
+ periodType: 'monthly',
142
+ timezone: 'UTC',
143
+ currency: 'USD',
144
+ rejectUnpricedModel: true,
145
+ missingUsageBehavior: 'use_reserved',
146
+ contextOverflowBehavior: 'reject',
147
+ allowedLlmServices: ['svc', null, 42],
148
+ allowAllModels: false,
149
+ allowedModels: [{ k: 1 }, 'svc/model-a'],
150
+ },
151
+ });
152
+ await db.getRepository('aiApiGroupMembers').create({
153
+ values: { groupId: custom.get('id'), userId: 44 },
154
+ });
155
+
156
+ const group = await resolveUserGroup(context(), 44);
157
+ expect(group.allowedLlmServices).toEqual(['svc']);
158
+ expect(group.allowedModels).toEqual(['svc/model-a']);
159
+ });
160
+ });
@@ -10,6 +10,7 @@ describe('AI API usage monitor summary', () => {
10
10
  inputTokens: '100',
11
11
  outputTokens: '25',
12
12
  totalTokens: '125',
13
+ promptCacheTokens: '40',
13
14
  });
14
15
  const findAll = vi.fn().mockResolvedValue([
15
16
  { currency: 'USD', totalCost: '0.12500000' },
@@ -53,6 +54,7 @@ describe('AI API usage monitor summary', () => {
53
54
  inputTokens: 100,
54
55
  outputTokens: 25,
55
56
  totalTokens: 125,
57
+ promptCacheTokens: 40,
56
58
  costsByCurrency: [
57
59
  { currency: 'USD', totalCost: '0.12500000' },
58
60
  { currency: 'EUR', totalCost: '0.05000000' },
@@ -15,7 +15,15 @@ interface ModelResult {
15
15
  usage_metadata?: Record<string, unknown>;
16
16
  }
17
17
 
18
- function createContext(result: ModelResult) {
18
+ interface RepositoryOverride {
19
+ findOne: () => unknown;
20
+ }
21
+
22
+ function createContext(
23
+ result: ModelResult,
24
+ metadataGet?: (key: string) => unknown,
25
+ repositories?: Record<string, RepositoryOverride>,
26
+ ) {
19
27
  const model = {
20
28
  invoke: vi.fn().mockResolvedValue(result),
21
29
  modelKwargs: {},
@@ -28,6 +36,10 @@ function createContext(result: ModelResult) {
28
36
  }
29
37
  }
30
38
 
39
+ const getMetadataValue =
40
+ metadataGet ??
41
+ ((key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true));
42
+
31
43
  const ctx = {
32
44
  app: {
33
45
  pm: {
@@ -40,13 +52,21 @@ function createContext(result: ModelResult) {
40
52
  },
41
53
  db: {
42
54
  getRepository: vi.fn((name: string) => {
55
+ if (repositories?.[name]) {
56
+ return repositories[name];
57
+ }
43
58
  if (name === 'aiApiModelMetadata') {
44
59
  return {
45
60
  findOne: vi.fn().mockResolvedValue({
46
- get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
61
+ get: getMetadataValue,
47
62
  }),
48
63
  };
49
64
  }
65
+ if (name === 'aiApiUsageGroups') {
66
+ return {
67
+ findOne: vi.fn().mockResolvedValue({ id: 1, name: 'Default', isDefault: true, allowAllModels: true }),
68
+ };
69
+ }
50
70
  return { findOne: vi.fn().mockResolvedValue(null) };
51
71
  }),
52
72
  },
@@ -160,6 +180,11 @@ function createStreamingContext(
160
180
  }),
161
181
  };
162
182
  }
183
+ if (name === 'aiApiUsageGroups') {
184
+ return {
185
+ findOne: vi.fn().mockResolvedValue({ id: 1, name: 'Default', isDefault: true, allowAllModels: true }),
186
+ };
187
+ }
163
188
  return { findOne: vi.fn().mockResolvedValue(null) };
164
189
  }),
165
190
  },
@@ -182,6 +207,15 @@ function createStreamingContext(
182
207
  return { ctx, model, writes };
183
208
  }
184
209
 
210
+ const metadataWithSystemPrompt = (key: string) =>
211
+ key === 'contextWindow'
212
+ ? 128_000
213
+ : key === 'maxCompletionTokens'
214
+ ? 16_384
215
+ : key === 'systemPrompt'
216
+ ? 'You are the initial prompt.'
217
+ : true;
218
+
185
219
  describe('AI API chat usage collection', () => {
186
220
  beforeEach(() => {
187
221
  vi.mocked(resolveModelString).mockResolvedValue({
@@ -343,6 +377,91 @@ describe('AI API chat usage collection', () => {
343
377
  });
344
378
  });
345
379
 
380
+ it('persists cached prompt tokens from streaming chat completions', async () => {
381
+ const { ctx, writes } = createStreamingContext(
382
+ {
383
+ content: 'Hi',
384
+ usage_metadata: {
385
+ input_tokens: 100,
386
+ output_tokens: 50,
387
+ total_tokens: 150,
388
+ input_token_details: { cache_read: 80 },
389
+ },
390
+ },
391
+ { include_usage: true },
392
+ );
393
+
394
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
395
+
396
+ const frames = writes
397
+ .filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
398
+ .map((line) => JSON.parse(line.slice(6)));
399
+ const usageChunk = frames[frames.length - 1];
400
+
401
+ expect(usageChunk.usage).toEqual({
402
+ prompt_tokens: 100,
403
+ completion_tokens: 50,
404
+ total_tokens: 150,
405
+ prompt_tokens_details: { cached_tokens: 80 },
406
+ });
407
+ expect(ctx.state.aiApiUsageResult).toMatchObject({
408
+ source: 'provider',
409
+ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150, prompt_cache_tokens: 80 },
410
+ });
411
+ });
412
+
413
+ it('falls back to the usage chunk response_metadata in streaming chat completions', async () => {
414
+ const { ctx } = createStreamingContext(
415
+ {
416
+ content: 'Hi',
417
+ usage_metadata: { input_tokens: 12, output_tokens: 6, total_tokens: 18 },
418
+ response_metadata: { usage: { prompt_tokens_details: { cached_tokens: 9 } } },
419
+ },
420
+ { include_usage: true },
421
+ );
422
+
423
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
424
+
425
+ expect(ctx.state.aiApiUsageResult).toMatchObject({
426
+ source: 'provider',
427
+ usage: { prompt_tokens: 12, completion_tokens: 6, total_tokens: 18, prompt_cache_tokens: 9 },
428
+ });
429
+ });
430
+
431
+ it('persists cached prompt tokens from streaming legacy completions', async () => {
432
+ const { ctx, writes } = createStreamingContext(
433
+ {
434
+ content: 'Hi',
435
+ usage_metadata: {
436
+ input_tokens: 30,
437
+ output_tokens: 10,
438
+ total_tokens: 40,
439
+ input_token_details: { cache_read: 25 },
440
+ },
441
+ },
442
+ { include_usage: true },
443
+ { prompt: 'Hello' },
444
+ );
445
+
446
+ await handleCompletions(ctx, {} as PluginAiApiServer);
447
+
448
+ const frames = writes
449
+ .filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
450
+ .map((line) => JSON.parse(line.slice(6)));
451
+ const usageChunk = frames[frames.length - 1];
452
+
453
+ expect(usageChunk.usage).toEqual({
454
+ prompt_tokens: 30,
455
+ completion_tokens: 10,
456
+ total_tokens: 40,
457
+ prompt_tokens_details: { cached_tokens: 25 },
458
+ });
459
+ expect(ctx.state.aiApiUsageResult).toMatchObject({
460
+ source: 'provider',
461
+ usage: { prompt_cache_tokens: 25 },
462
+ });
463
+ });
464
+
346
465
  it('always emits chat usage and forces provider collection when include_usage is false', async () => {
347
466
  const { ctx, model, writes } = createStreamingContext(
348
467
  {
@@ -434,6 +553,82 @@ describe('AI API chat usage collection', () => {
434
553
  expect(model.invoke).not.toHaveBeenCalled();
435
554
  });
436
555
 
556
+ it('prepends the initial system prompt from model metadata in chat completions', async () => {
557
+ const { ctx, model } = createContext({ content: 'Hello back' }, metadataWithSystemPrompt);
558
+
559
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
560
+
561
+ expect(ctx.status).toBe(200);
562
+ const invokedMessages = model.invoke.mock.calls[0][0] as [string, unknown][];
563
+ expect(invokedMessages).toEqual([
564
+ ['system', 'You are the initial prompt.'],
565
+ ['user', 'Hello'],
566
+ ]);
567
+ });
568
+
569
+ it('keeps the client system prompt after the initial system prompt in chat completions', async () => {
570
+ const { ctx, model } = createContext({ content: 'Hello back' }, metadataWithSystemPrompt);
571
+ (ctx.request.body as Record<string, unknown>).messages = [
572
+ { role: 'system', content: 'Client system prompt' },
573
+ { role: 'user', content: 'Hello' },
574
+ ];
575
+
576
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
577
+
578
+ expect(ctx.status).toBe(200);
579
+ const invokedMessages = model.invoke.mock.calls[0][0] as [string, unknown][];
580
+ expect(invokedMessages).toEqual([
581
+ ['system', 'You are the initial prompt.'],
582
+ ['system', 'Client system prompt'],
583
+ ['user', 'Hello'],
584
+ ]);
585
+ });
586
+
587
+ it('prepends the initial system prompt in legacy completions', async () => {
588
+ const { ctx, model } = createContext({ content: 'Hello back' }, metadataWithSystemPrompt);
589
+ (ctx.request.body as Record<string, unknown>).prompt = 'Hello';
590
+
591
+ await handleCompletions(ctx, {} as PluginAiApiServer);
592
+
593
+ expect(ctx.status).toBe(200);
594
+ const invokedMessages = model.invoke.mock.calls[0][0] as [string, unknown][];
595
+ expect(invokedMessages).toEqual([
596
+ ['system', 'You are the initial prompt.'],
597
+ ['human', 'Hello'],
598
+ ]);
599
+ });
600
+
601
+ it('ignores the default AI Employee prompt in direct LLM chat completions', async () => {
602
+ const employeeFindOne = vi.fn().mockResolvedValue({ about: 'Employee persona prompt' });
603
+ const { ctx, model } = createContext({ content: 'Hello back' }, undefined, {
604
+ aiApiConfig: { findOne: vi.fn().mockResolvedValue({ defaultAiEmployee: 'alice' }) },
605
+ aiEmployees: { findOne: employeeFindOne },
606
+ });
607
+
608
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
609
+
610
+ expect(ctx.status).toBe(200);
611
+ expect(employeeFindOne).not.toHaveBeenCalled();
612
+ const invokedMessages = model.invoke.mock.calls[0][0] as [string, unknown][];
613
+ expect(invokedMessages).toEqual([['user', 'Hello']]);
614
+ });
615
+
616
+ it('ignores the default AI Employee prompt in legacy completions', async () => {
617
+ const employeeFindOne = vi.fn().mockResolvedValue({ about: 'Employee persona prompt' });
618
+ const { ctx, model } = createContext({ content: 'Hello back' }, undefined, {
619
+ aiApiConfig: { findOne: vi.fn().mockResolvedValue({ defaultAiEmployee: 'alice' }) },
620
+ aiEmployees: { findOne: employeeFindOne },
621
+ });
622
+ (ctx.request.body as Record<string, unknown>).prompt = 'Hello';
623
+
624
+ await handleCompletions(ctx, {} as PluginAiApiServer);
625
+
626
+ expect(ctx.status).toBe(200);
627
+ expect(employeeFindOne).not.toHaveBeenCalled();
628
+ const invokedMessages = model.invoke.mock.calls[0][0] as [string, unknown][];
629
+ expect(invokedMessages).toEqual([['human', 'Hello']]);
630
+ });
631
+
437
632
  it('does not emit a usage-only chunk when the provider omits usage metadata', async () => {
438
633
  const { ctx, writes } = createStreamingContext({ content: 'Silent' }, { include_usage: true });
439
634
 
@@ -483,4 +678,69 @@ describe('AI API chat usage collection', () => {
483
678
  expect.objectContaining({ seed: 42, reasoning_effort: 'medium' }),
484
679
  );
485
680
  });
681
+
682
+ it('propagates the provider finish_reason in non-streaming chat completions', async () => {
683
+ const { ctx } = createContext({
684
+ content: 'Hello back',
685
+ response_metadata: { finish_reason: 'length' },
686
+ usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
687
+ });
688
+
689
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
690
+
691
+ expect(ctx.status).toBe(200);
692
+ expect((ctx.body as { choices: Array<{ finish_reason: string }> }).choices[0].finish_reason).toBe('length');
693
+ });
694
+
695
+ it('propagates the provider finish_reason in streaming chat completions', async () => {
696
+ const { ctx, writes } = createStreamingContext(
697
+ {
698
+ content: 'Hi',
699
+ response_metadata: { finish_reason: 'length' },
700
+ usage_metadata: { input_tokens: 5, output_tokens: 4, total_tokens: 9 },
701
+ },
702
+ { include_usage: true },
703
+ );
704
+
705
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
706
+
707
+ const frames = writes
708
+ .filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
709
+ .map((line) => JSON.parse(line.slice(6)));
710
+ const finishChunk = frames[frames.length - 2];
711
+ expect(finishChunk.choices[0].finish_reason).toBe('length');
712
+ });
713
+
714
+ it('propagates the provider finish_reason in legacy completions', async () => {
715
+ const { ctx } = createContext({
716
+ content: 'Hello back',
717
+ response_metadata: { finish_reason: 'length' },
718
+ });
719
+ (ctx.request.body as Record<string, unknown>).prompt = 'Hello';
720
+
721
+ await handleCompletions(ctx, {} as PluginAiApiServer);
722
+
723
+ expect(ctx.status).toBe(200);
724
+ expect((ctx.body as { choices: Array<{ finish_reason: string }> }).choices[0].finish_reason).toBe('length');
725
+ });
726
+
727
+ it('propagates the provider finish_reason in streaming legacy completions', async () => {
728
+ const { ctx, writes } = createStreamingContext(
729
+ {
730
+ content: 'Hi',
731
+ response_metadata: { finish_reason: 'length' },
732
+ usage_metadata: { input_tokens: 2, output_tokens: 5, total_tokens: 7 },
733
+ },
734
+ { include_usage: true },
735
+ { prompt: 'Hello' },
736
+ );
737
+
738
+ await handleCompletions(ctx, {} as PluginAiApiServer);
739
+
740
+ const frames = writes
741
+ .filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
742
+ .map((line) => JSON.parse(line.slice(6)));
743
+ const finishChunk = frames[frames.length - 2];
744
+ expect(finishChunk.choices[0].finish_reason).toBe('length');
745
+ });
486
746
  });
@@ -54,6 +54,44 @@ describe('AI API usage normalization', () => {
54
54
  });
55
55
  });
56
56
 
57
+ it('is idempotent so streaming double-normalization keeps prompt_cache_tokens', () => {
58
+ const streamChunkUsage = {
59
+ input_tokens: 100,
60
+ output_tokens: 50,
61
+ total_tokens: 150,
62
+ input_token_details: { cache_read: 80 },
63
+ };
64
+ const firstPass = normalizeUsage(streamChunkUsage);
65
+ expect(firstPass?.prompt_cache_tokens).toBe(80);
66
+
67
+ // Streaming routes normalize the chunk once, then setAiApiUsageResult
68
+ // normalizes the result again — the extracted value must survive.
69
+ expect(normalizeUsage(firstPass)).toEqual(firstPass);
70
+ });
71
+
72
+ it('keeps prompt_cache_tokens when setAiApiUsageResult receives pre-normalized usage', () => {
73
+ const ctx = createContext();
74
+ const preNormalized = normalizeUsage({
75
+ input_tokens: 100,
76
+ output_tokens: 50,
77
+ total_tokens: 150,
78
+ input_token_details: { cache_read: 80 },
79
+ });
80
+
81
+ const usage = setAiApiUsageResult(ctx, preNormalized, { gatewayResponseId: 'gateway-stream-id' });
82
+
83
+ expect(usage).toEqual({
84
+ prompt_tokens: 100,
85
+ completion_tokens: 50,
86
+ total_tokens: 150,
87
+ prompt_cache_tokens: 80,
88
+ });
89
+ expect(ctx.state.aiApiUsageResult).toMatchObject({
90
+ source: 'provider',
91
+ usage: { prompt_cache_tokens: 80 },
92
+ });
93
+ });
94
+
57
95
  it('rejects synthetic or invalid usage values', () => {
58
96
  expect(normalizeUsage(undefined)).toBeUndefined();
59
97
  expect(normalizeUsage({ prompt_tokens: '12', completion_tokens: -1, total_tokens: Number.NaN })).toBeUndefined();