plugin-ai-api 1.0.25 → 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.
- package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/locale/en-US.json +26 -8
- package/dist/locale/vi-VN.json +26 -8
- package/dist/locale/zh-CN.json +26 -8
- package/dist/server/billing.js +25 -32
- package/dist/server/collections/ai-api-config.js +1 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +90 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +0 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +32 -32
- package/dist/server/routes/completions.js +16 -19
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +186 -22
- package/dist/server/usage.js +5 -1
- package/dist/server/utils/direct-llm-context.js +13 -11
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +6 -7
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +26 -8
- package/src/locale/vi-VN.json +26 -8
- package/src/locale/zh-CN.json +26 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +122 -4
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +262 -2
- package/src/server/__tests__/usage.test.ts +38 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +30 -38
- package/src/server/collections/ai-api-config.ts +1 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/middleware/rate-limit.ts +10 -12
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +101 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +0 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +39 -36
- package/src/server/routes/completions.ts +18 -21
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/file-processor.ts +214 -24
- package/src/server/usage.ts +5 -1
- package/src/server/utils/direct-llm-context.ts +20 -11
- package/src/server/utils/rate-limiter.ts +1 -1
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +2 -1
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +7 -8
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- 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 {
|
|
13
|
+
import { invalidateGroupAccessCache, resolveUserAccessScope } from '../utils/user-permissions';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Covers the cross-node half of
|
|
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
|
|
24
|
-
|
|
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: {
|
|
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
|
-
|
|
43
|
+
invalidateGroupAccessCache();
|
|
40
44
|
});
|
|
41
45
|
|
|
42
|
-
describe('cross-node
|
|
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
|
|
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: '
|
|
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
|
|
59
|
+
it('ignores unrelated message types and other groups', async () => {
|
|
58
60
|
const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
|
|
59
|
-
const { ctx
|
|
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',
|
|
63
|
-
await plugin.handleSyncMessage({ type: '
|
|
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
|
|
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
|
-
//
|
|
85
|
-
(plugin as unknown as {
|
|
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: '
|
|
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 {
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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();
|