plugin-ai-api 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +175 -139
- package/dist/locale/vi-VN.json +40 -2
- package/dist/locale/zh-CN.json +40 -2
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +25 -6
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +25 -4
- package/dist/server/utils/direct-llm-context.js +1 -1
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +44 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/locale/en-US.json +175 -139
- package/src/locale/vi-VN.json +40 -2
- package/src/locale/zh-CN.json +40 -2
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +14 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +32 -10
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +29 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +2 -1
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +49 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time structural contracts: local response shapes must be assignable to
|
|
3
|
+
* the OpenAI SDK types. If this file compiles, the runtime payload shape is SDK-compatible.
|
|
4
|
+
* Run with: tsc -p tsconfig.test.json --noEmit
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
Response as OpenAIResponse,
|
|
8
|
+
ResponseCreatedEvent,
|
|
9
|
+
ResponseCompletedEvent,
|
|
10
|
+
ResponseUsage,
|
|
11
|
+
} from 'openai/resources/responses/responses';
|
|
12
|
+
import type { ResponseObject } from '../../utils/responses-format';
|
|
13
|
+
|
|
14
|
+
// ─── ResponseObject must be assignable to the SDK Response type ──────────────
|
|
15
|
+
declare const responseObject: ResponseObject;
|
|
16
|
+
const sdkResponse: OpenAIResponse = responseObject;
|
|
17
|
+
|
|
18
|
+
// ─── ResponseStreamEvent terminal payloads must be SDK-compatible ────────────
|
|
19
|
+
declare const response: OpenAIResponse;
|
|
20
|
+
const sdkCreated: ResponseCreatedEvent = { type: 'response.created', response, sequence_number: 0 };
|
|
21
|
+
const sdkCompleted: ResponseCompletedEvent = { type: 'response.completed', response, sequence_number: 0 };
|
|
22
|
+
|
|
23
|
+
export { sdkResponse, sdkCreated, sdkCompleted };
|
|
@@ -130,6 +130,102 @@ describe('AI API usage groups', () => {
|
|
|
130
130
|
expect(group.allowedModels).toEqual(['svc/model-a']);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
it('rejects adding a member to the default group via the beforeSave guard', async () => {
|
|
134
|
+
// Mirror the plugin.ts aiApiGroupMembers.beforeSave hook: adding a member to the
|
|
135
|
+
// default group is rejected because the default group is a fallback, not a target.
|
|
136
|
+
const defaultGroup = await db.getRepository('aiApiUsageGroups').create({
|
|
137
|
+
values: {
|
|
138
|
+
name: 'Default',
|
|
139
|
+
isDefault: true,
|
|
140
|
+
quotaMode: 'per_user',
|
|
141
|
+
rateLimitPerMinute: 60,
|
|
142
|
+
enabled: false,
|
|
143
|
+
periodType: 'monthly',
|
|
144
|
+
timezone: 'UTC',
|
|
145
|
+
currency: 'USD',
|
|
146
|
+
rejectUnpricedModel: true,
|
|
147
|
+
missingUsageBehavior: 'use_reserved',
|
|
148
|
+
contextOverflowBehavior: 'reject',
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
db.on('aiApiGroupMembers.beforeSave', async (model) => {
|
|
153
|
+
const targetGroupId = model.get('groupId');
|
|
154
|
+
if (!targetGroupId) return;
|
|
155
|
+
const targetGroup = await db.getRepository('aiApiUsageGroups').findOne({ filterByTk: targetGroupId });
|
|
156
|
+
if (targetGroup?.get('isDefault')) {
|
|
157
|
+
throw new Error('Cannot add members to the default group.');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await expect(
|
|
162
|
+
db.getRepository('aiApiGroupMembers').create({ values: { groupId: defaultGroup.get('id'), userId: 7 } }),
|
|
163
|
+
).rejects.toThrow('Cannot add members to the default group');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('drops members of a deleted group so they fall back to the default group', async () => {
|
|
167
|
+
// Mirror the plugin.ts aiApiUsageGroups.beforeDestroy hook: deleting a non-default
|
|
168
|
+
// group destroys its member rows, and affected users resolve to the default group
|
|
169
|
+
// implicitly instead of being reassigned to an explicit default membership.
|
|
170
|
+
await db.getRepository('aiApiUsageGroups').create({
|
|
171
|
+
values: {
|
|
172
|
+
name: 'Default',
|
|
173
|
+
isDefault: true,
|
|
174
|
+
quotaMode: 'per_user',
|
|
175
|
+
rateLimitPerMinute: 60,
|
|
176
|
+
enabled: false,
|
|
177
|
+
periodType: 'monthly',
|
|
178
|
+
timezone: 'UTC',
|
|
179
|
+
currency: 'USD',
|
|
180
|
+
rejectUnpricedModel: true,
|
|
181
|
+
missingUsageBehavior: 'use_reserved',
|
|
182
|
+
contextOverflowBehavior: 'reject',
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const custom = await db.getRepository('aiApiUsageGroups').create({
|
|
186
|
+
values: {
|
|
187
|
+
name: 'Pro',
|
|
188
|
+
isDefault: false,
|
|
189
|
+
quotaMode: 'share',
|
|
190
|
+
rateLimitPerMinute: 120,
|
|
191
|
+
enabled: true,
|
|
192
|
+
periodType: 'monthly',
|
|
193
|
+
timezone: 'UTC',
|
|
194
|
+
currency: 'USD',
|
|
195
|
+
rejectUnpricedModel: true,
|
|
196
|
+
missingUsageBehavior: 'use_reserved',
|
|
197
|
+
contextOverflowBehavior: 'reject',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
await db.getRepository('aiApiGroupMembers').create({
|
|
201
|
+
values: { groupId: custom.get('id'), userId: 55 },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
db.on('aiApiUsageGroups.beforeDestroy', async (model, options) => {
|
|
205
|
+
if (model.get('isDefault')) {
|
|
206
|
+
throw new Error('The default usage group cannot be deleted.');
|
|
207
|
+
}
|
|
208
|
+
const members = await db.getRepository('aiApiGroupMembers').find({
|
|
209
|
+
filter: { groupId: model.get('id') },
|
|
210
|
+
transaction: options?.transaction,
|
|
211
|
+
});
|
|
212
|
+
for (const member of members) {
|
|
213
|
+
await db.getRepository('aiApiGroupMembers').destroy({
|
|
214
|
+
filterByTk: member.get('id'),
|
|
215
|
+
transaction: options?.transaction,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await db.getRepository('aiApiUsageGroups').destroy({ filterByTk: custom.get('id') });
|
|
221
|
+
|
|
222
|
+
const remaining = await db.getRepository('aiApiGroupMembers').findOne({ filter: { userId: 55 } });
|
|
223
|
+
expect(remaining).toBeNull();
|
|
224
|
+
// With no explicit membership, the user now resolves to the default group.
|
|
225
|
+
const resolved = await resolveUserGroup(context(), 55);
|
|
226
|
+
expect(resolved.name).toBe('Default');
|
|
227
|
+
expect(resolved.isDefault).toBe(true);
|
|
228
|
+
});
|
|
133
229
|
it('drops non-string entries from the access lists', async () => {
|
|
134
230
|
const custom = await db.getRepository('aiApiUsageGroups').create({
|
|
135
231
|
values: {
|
|
@@ -25,6 +25,7 @@ describe('AI API usage normalization', () => {
|
|
|
25
25
|
completion_tokens: 5,
|
|
26
26
|
total_tokens: 17,
|
|
27
27
|
prompt_cache_tokens: null,
|
|
28
|
+
reasoning_tokens: null,
|
|
28
29
|
});
|
|
29
30
|
});
|
|
30
31
|
|
|
@@ -34,6 +35,7 @@ describe('AI API usage normalization', () => {
|
|
|
34
35
|
completion_tokens: 0,
|
|
35
36
|
total_tokens: 0,
|
|
36
37
|
prompt_cache_tokens: null,
|
|
38
|
+
reasoning_tokens: null,
|
|
37
39
|
});
|
|
38
40
|
});
|
|
39
41
|
|
|
@@ -45,15 +47,26 @@ describe('AI API usage normalization', () => {
|
|
|
45
47
|
completion_tokens: 5,
|
|
46
48
|
total_tokens: 15,
|
|
47
49
|
prompt_cache_tokens: 8,
|
|
50
|
+
reasoning_tokens: null,
|
|
48
51
|
});
|
|
49
52
|
expect(normalizeUsage({ input_tokens: 20, output_tokens: 10, input_token_details: { cache_read: 15 } })).toEqual({
|
|
50
53
|
prompt_tokens: 20,
|
|
51
54
|
completion_tokens: 10,
|
|
52
55
|
total_tokens: 30,
|
|
53
56
|
prompt_cache_tokens: 15,
|
|
57
|
+
reasoning_tokens: null,
|
|
54
58
|
});
|
|
55
59
|
});
|
|
56
60
|
|
|
61
|
+
it('extracts reasoning token details from LangChain and OpenAI usage shapes', () => {
|
|
62
|
+
expect(
|
|
63
|
+
normalizeUsage({ input_tokens: 10, output_tokens: 5, output_token_details: { reasoning: 3 } }),
|
|
64
|
+
).toMatchObject({ reasoning_tokens: 3 });
|
|
65
|
+
expect(
|
|
66
|
+
normalizeUsage({ prompt_tokens: 10, completion_tokens: 5, completion_tokens_details: { reasoning_tokens: 4 } }),
|
|
67
|
+
).toMatchObject({ reasoning_tokens: 4 });
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
it('is idempotent so streaming double-normalization keeps prompt_cache_tokens', () => {
|
|
58
71
|
const streamChunkUsage = {
|
|
59
72
|
input_tokens: 100,
|
|
@@ -85,6 +98,7 @@ describe('AI API usage normalization', () => {
|
|
|
85
98
|
completion_tokens: 50,
|
|
86
99
|
total_tokens: 150,
|
|
87
100
|
prompt_cache_tokens: 80,
|
|
101
|
+
reasoning_tokens: null,
|
|
88
102
|
});
|
|
89
103
|
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
90
104
|
source: 'provider',
|
|
@@ -5,9 +5,9 @@ vi.mock('dayjs', () => ({
|
|
|
5
5
|
default: () => ({ tz: vi.fn() }),
|
|
6
6
|
}));
|
|
7
7
|
|
|
8
|
-
import { validateQuotaPolicy } from '../validation';
|
|
8
|
+
import { validateModelMetadata, validateQuotaPolicy, validateVirtualModel } from '../validation';
|
|
9
9
|
|
|
10
|
-
function
|
|
10
|
+
function record(values: Record<string, unknown>): Model {
|
|
11
11
|
return {
|
|
12
12
|
get: (key: string) => values[key],
|
|
13
13
|
} as Model;
|
|
@@ -22,26 +22,85 @@ const requiredPolicy = {
|
|
|
22
22
|
|
|
23
23
|
describe('AI API quota policy validation', () => {
|
|
24
24
|
it('uses reject when context overflow behavior is absent', () => {
|
|
25
|
-
expect(() => validateQuotaPolicy(
|
|
25
|
+
expect(() => validateQuotaPolicy(record(requiredPolicy))).not.toThrow();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
it.each(['reject', 'truncate'] as const)('accepts %s context overflow behavior', (contextOverflowBehavior) => {
|
|
29
|
-
expect(() => validateQuotaPolicy(
|
|
29
|
+
expect(() => validateQuotaPolicy(record({ ...requiredPolicy, contextOverflowBehavior }))).not.toThrow();
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it('rejects an unsupported context overflow behavior', () => {
|
|
33
|
-
expect(() => validateQuotaPolicy(
|
|
33
|
+
expect(() => validateQuotaPolicy(record({ ...requiredPolicy, contextOverflowBehavior: 'compact' }))).toThrow(
|
|
34
34
|
'contextOverflowBehavior must be reject or truncate.',
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
it.each(['share', 'per_user'] as const)('accepts %s quota mode', (quotaMode) => {
|
|
39
|
-
expect(() => validateQuotaPolicy(
|
|
39
|
+
expect(() => validateQuotaPolicy(record({ ...requiredPolicy, quotaMode }))).not.toThrow();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it('rejects an unsupported quota mode', () => {
|
|
43
|
-
expect(() => validateQuotaPolicy(
|
|
43
|
+
expect(() => validateQuotaPolicy(record({ ...requiredPolicy, quotaMode: 'global' }))).toThrow(
|
|
44
44
|
'quotaMode must be share or per_user.',
|
|
45
45
|
);
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
describe('AI API model metadata validation', () => {
|
|
50
|
+
const validMetadata = {
|
|
51
|
+
llmService: 'openai',
|
|
52
|
+
model: 'gpt-5',
|
|
53
|
+
supportsVision: true,
|
|
54
|
+
supportsToolCalling: true,
|
|
55
|
+
reasoningTier: 'general',
|
|
56
|
+
sortOrder: 0,
|
|
57
|
+
enabled: true,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
it('accepts routing metadata fields', () => {
|
|
61
|
+
expect(() => validateModelMetadata(record(validMetadata))).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('accepts omitted fields that receive collection defaults', () => {
|
|
65
|
+
expect(() => validateModelMetadata(record({ llmService: 'openai', model: 'gpt-5' }))).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
[{ ...validMetadata, reasoningTier: 'fast' }, 'reasoningTier must be cheap, general, or reasoning.'],
|
|
70
|
+
[{ ...validMetadata, sortOrder: 1.5 }, 'sortOrder must be an integer.'],
|
|
71
|
+
[{ ...validMetadata, supportsVision: 'yes' }, 'supportsVision must be a boolean.'],
|
|
72
|
+
])('rejects invalid routing metadata', (values, message) => {
|
|
73
|
+
expect(() => validateModelMetadata(record(values))).toThrow(message);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('AI API virtual model validation', () => {
|
|
78
|
+
const validVirtualModel = {
|
|
79
|
+
name: 'auto',
|
|
80
|
+
mode: 'chat',
|
|
81
|
+
fallbackModel: 'openai/gpt-5',
|
|
82
|
+
visionModels: [],
|
|
83
|
+
toolModels: ['openai/gpt-5'],
|
|
84
|
+
reasoningModels: [],
|
|
85
|
+
cheapModels: [],
|
|
86
|
+
generalModels: [],
|
|
87
|
+
enabled: true,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
it('accepts a complete virtual model', () => {
|
|
91
|
+
expect(() => validateVirtualModel(record(validVirtualModel))).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('accepts omitted fields that receive collection defaults', () => {
|
|
95
|
+
expect(() => validateVirtualModel(record({ name: 'auto', fallbackModel: 'openai/gpt-5' }))).not.toThrow();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each([
|
|
99
|
+
[{ ...validVirtualModel, mode: 'audio' }, 'mode must be chat or embedding.'],
|
|
100
|
+
[{ ...validVirtualModel, fallbackModel: 'gpt-5' }, 'fallbackModel must use the service/modelId format.'],
|
|
101
|
+
[{ ...validVirtualModel, toolModels: 'openai/gpt-5' }, 'toolModels must be an array.'],
|
|
102
|
+
[{ ...validVirtualModel, visionModels: ['invalid'] }, 'visionModels[0] must use the service/modelId format.'],
|
|
103
|
+
])('rejects an invalid virtual model', (values, message) => {
|
|
104
|
+
expect(() => validateVirtualModel(record(values))).toThrow(message);
|
|
105
|
+
});
|
|
106
|
+
});
|