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.
- 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.6568d3504ad29352.js +10 -0
- 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.f2bc9cfba07004b0.js +10 -0
- 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/externalVersion.js +8 -8
- package/dist/locale/en-US.json +27 -8
- package/dist/locale/vi-VN.json +27 -8
- package/dist/locale/zh-CN.json +27 -8
- package/dist/server/billing.js +31 -33
- package/dist/server/collections/ai-api-config.js +7 -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 +2 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- 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 +100 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +5 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +121 -42
- package/dist/server/routes/completions.js +48 -29
- 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 +426 -0
- package/dist/server/usage.js +37 -3
- package/dist/server/utils/direct-llm-context.js +163 -26
- package/dist/server/utils/openai-format.js +21 -2
- 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 +48 -10
- 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/pages/UsagePage.tsx +9 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +27 -8
- package/src/locale/vi-VN.json +27 -8
- package/src/locale/zh-CN.json +27 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +209 -10
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/request-body.test.ts +45 -2
- 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 +382 -5
- package/src/server/__tests__/usage.test.ts +57 -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 +36 -39
- package/src/server/collections/ai-api-config.ts +9 -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-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +2 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +68 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- 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 +121 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +5 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +173 -47
- package/src/server/routes/completions.ts +50 -27
- 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/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +513 -0
- package/src/server/usage.ts +51 -1
- package/src/server/utils/direct-llm-context.ts +218 -31
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +83 -82
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +52 -11
- 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/757.a01403fb7a1bea01.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/757.a117ce1cf7119cea.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
|
@@ -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({
|
|
@@ -205,6 +239,7 @@ describe('AI API chat usage collection', () => {
|
|
|
205
239
|
prompt_tokens: 0,
|
|
206
240
|
completion_tokens: 0,
|
|
207
241
|
total_tokens: 0,
|
|
242
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
208
243
|
});
|
|
209
244
|
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
210
245
|
source: 'unavailable',
|
|
@@ -229,6 +264,7 @@ describe('AI API chat usage collection', () => {
|
|
|
229
264
|
prompt_tokens: 8,
|
|
230
265
|
completion_tokens: 3,
|
|
231
266
|
total_tokens: 11,
|
|
267
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
232
268
|
});
|
|
233
269
|
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
234
270
|
source: 'provider',
|
|
@@ -238,6 +274,73 @@ describe('AI API chat usage collection', () => {
|
|
|
238
274
|
});
|
|
239
275
|
});
|
|
240
276
|
|
|
277
|
+
it('extracts cached prompt tokens from OpenAI-style usage metadata', async () => {
|
|
278
|
+
const { ctx } = createContext({
|
|
279
|
+
content: 'Hello back',
|
|
280
|
+
usage_metadata: {
|
|
281
|
+
input_tokens: 8,
|
|
282
|
+
output_tokens: 3,
|
|
283
|
+
total_tokens: 11,
|
|
284
|
+
prompt_tokens_details: { cached_tokens: 7 },
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
289
|
+
|
|
290
|
+
expect((ctx.body as { usage: object }).usage).toEqual({
|
|
291
|
+
prompt_tokens: 8,
|
|
292
|
+
completion_tokens: 3,
|
|
293
|
+
total_tokens: 11,
|
|
294
|
+
prompt_tokens_details: { cached_tokens: 7 },
|
|
295
|
+
});
|
|
296
|
+
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
297
|
+
source: 'provider',
|
|
298
|
+
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11, prompt_cache_tokens: 7 },
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('falls back to response_metadata when usage metadata omits cached tokens', async () => {
|
|
303
|
+
const { ctx } = createContext({
|
|
304
|
+
content: 'Hello back',
|
|
305
|
+
usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
|
|
306
|
+
response_metadata: { usage: { prompt_tokens_details: { cached_tokens: 5 } } },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
310
|
+
|
|
311
|
+
expect((ctx.body as { usage: object }).usage).toEqual({
|
|
312
|
+
prompt_tokens: 8,
|
|
313
|
+
completion_tokens: 3,
|
|
314
|
+
total_tokens: 11,
|
|
315
|
+
prompt_tokens_details: { cached_tokens: 5 },
|
|
316
|
+
});
|
|
317
|
+
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
318
|
+
source: 'provider',
|
|
319
|
+
usage: { prompt_cache_tokens: 5 },
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('extracts cached prompt tokens from LangChain-style input_token_details', async () => {
|
|
324
|
+
const { ctx } = createContext({
|
|
325
|
+
content: 'Hello back',
|
|
326
|
+
usage_metadata: {
|
|
327
|
+
input_tokens: 8,
|
|
328
|
+
output_tokens: 3,
|
|
329
|
+
total_tokens: 11,
|
|
330
|
+
input_token_details: { cache_read: 4 },
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
335
|
+
|
|
336
|
+
expect((ctx.body as { usage: object }).usage).toEqual({
|
|
337
|
+
prompt_tokens: 8,
|
|
338
|
+
completion_tokens: 3,
|
|
339
|
+
total_tokens: 11,
|
|
340
|
+
prompt_tokens_details: { cached_tokens: 4 },
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
241
344
|
it('emits a usage-only chunk immediately before [DONE] for streaming chat completions', async () => {
|
|
242
345
|
const { ctx, writes } = createStreamingContext(
|
|
243
346
|
{
|
|
@@ -261,7 +364,12 @@ describe('AI API chat usage collection', () => {
|
|
|
261
364
|
|
|
262
365
|
expect(finishChunk.choices[0].finish_reason).toBe('stop');
|
|
263
366
|
expect(usageChunk.choices).toEqual([]);
|
|
264
|
-
expect(usageChunk.usage).toEqual({
|
|
367
|
+
expect(usageChunk.usage).toEqual({
|
|
368
|
+
prompt_tokens: 5,
|
|
369
|
+
completion_tokens: 4,
|
|
370
|
+
total_tokens: 9,
|
|
371
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
372
|
+
});
|
|
265
373
|
expect(usageChunk).toHaveProperty('usage.prompt_tokens', 5);
|
|
266
374
|
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
267
375
|
source: 'provider',
|
|
@@ -269,6 +377,91 @@ describe('AI API chat usage collection', () => {
|
|
|
269
377
|
});
|
|
270
378
|
});
|
|
271
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
|
+
|
|
272
465
|
it('always emits chat usage and forces provider collection when include_usage is false', async () => {
|
|
273
466
|
const { ctx, model, writes } = createStreamingContext(
|
|
274
467
|
{
|
|
@@ -287,7 +480,12 @@ describe('AI API chat usage collection', () => {
|
|
|
287
480
|
|
|
288
481
|
expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
|
|
289
482
|
expect(usageChunk.choices).toEqual([]);
|
|
290
|
-
expect(usageChunk.usage).toEqual({
|
|
483
|
+
expect(usageChunk.usage).toEqual({
|
|
484
|
+
prompt_tokens: 5,
|
|
485
|
+
completion_tokens: 4,
|
|
486
|
+
total_tokens: 9,
|
|
487
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
488
|
+
});
|
|
291
489
|
expect(model.stream).toHaveBeenCalledWith(
|
|
292
490
|
expect.anything(),
|
|
293
491
|
expect.objectContaining({
|
|
@@ -316,7 +514,12 @@ describe('AI API chat usage collection', () => {
|
|
|
316
514
|
expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
|
|
317
515
|
expect(usageChunk.object).toBe('text_completion');
|
|
318
516
|
expect(usageChunk.choices).toEqual([]);
|
|
319
|
-
expect(usageChunk.usage).toEqual({
|
|
517
|
+
expect(usageChunk.usage).toEqual({
|
|
518
|
+
prompt_tokens: 2,
|
|
519
|
+
completion_tokens: 5,
|
|
520
|
+
total_tokens: 7,
|
|
521
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
522
|
+
});
|
|
320
523
|
expect(model.stream).toHaveBeenCalledWith(
|
|
321
524
|
expect.anything(),
|
|
322
525
|
expect.objectContaining({
|
|
@@ -350,6 +553,82 @@ describe('AI API chat usage collection', () => {
|
|
|
350
553
|
expect(model.invoke).not.toHaveBeenCalled();
|
|
351
554
|
});
|
|
352
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
|
+
|
|
353
632
|
it('does not emit a usage-only chunk when the provider omits usage metadata', async () => {
|
|
354
633
|
const { ctx, writes } = createStreamingContext({ content: 'Silent' }, { include_usage: true });
|
|
355
634
|
|
|
@@ -366,4 +645,102 @@ describe('AI API chat usage collection', () => {
|
|
|
366
645
|
expect(precedingChunk.usage).toBeNull();
|
|
367
646
|
expect(ctx.state.aiApiUsageResult).toMatchObject({ source: 'unavailable' });
|
|
368
647
|
});
|
|
648
|
+
|
|
649
|
+
it('forwards passthrough provider parameters in legacy non-stream completions', async () => {
|
|
650
|
+
const { ctx, model } = createContext({
|
|
651
|
+
content: 'Hello back',
|
|
652
|
+
usage_metadata: { input_tokens: 2, output_tokens: 5, total_tokens: 7 },
|
|
653
|
+
});
|
|
654
|
+
(ctx.request.body as Record<string, unknown>).prompt = 'Hello';
|
|
655
|
+
(ctx.request.body as Record<string, unknown>).seed = 42;
|
|
656
|
+
(ctx.request.body as Record<string, unknown>).reasoning_effort = 'medium';
|
|
657
|
+
|
|
658
|
+
await handleCompletions(ctx, {} as PluginAiApiServer);
|
|
659
|
+
|
|
660
|
+
expect(ctx.status).toBe(200);
|
|
661
|
+
expect(model.invoke).toHaveBeenCalledWith(
|
|
662
|
+
expect.anything(),
|
|
663
|
+
expect.objectContaining({ seed: 42, reasoning_effort: 'medium' }),
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('forwards passthrough provider parameters in legacy streaming completions', async () => {
|
|
668
|
+
const { ctx, model } = createStreamingContext(
|
|
669
|
+
{ content: 'Hi', usage_metadata: { input_tokens: 2, output_tokens: 5, total_tokens: 7 } },
|
|
670
|
+
{ include_usage: false, include_obfuscation: false },
|
|
671
|
+
{ prompt: 'Hello', seed: 42, reasoning_effort: 'medium' },
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
await handleCompletions(ctx, {} as PluginAiApiServer);
|
|
675
|
+
|
|
676
|
+
expect(model.stream).toHaveBeenCalledWith(
|
|
677
|
+
expect.anything(),
|
|
678
|
+
expect.objectContaining({ seed: 42, reasoning_effort: 'medium' }),
|
|
679
|
+
);
|
|
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
|
+
});
|
|
369
746
|
});
|
|
@@ -24,6 +24,7 @@ describe('AI API usage normalization', () => {
|
|
|
24
24
|
prompt_tokens: 12,
|
|
25
25
|
completion_tokens: 5,
|
|
26
26
|
total_tokens: 17,
|
|
27
|
+
prompt_cache_tokens: null,
|
|
27
28
|
});
|
|
28
29
|
});
|
|
29
30
|
|
|
@@ -32,6 +33,62 @@ describe('AI API usage normalization', () => {
|
|
|
32
33
|
prompt_tokens: 0,
|
|
33
34
|
completion_tokens: 0,
|
|
34
35
|
total_tokens: 0,
|
|
36
|
+
prompt_cache_tokens: null,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('extracts prompt_cache_tokens when present in various provider formats', () => {
|
|
41
|
+
expect(
|
|
42
|
+
normalizeUsage({ prompt_tokens: 10, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 8 } }),
|
|
43
|
+
).toEqual({
|
|
44
|
+
prompt_tokens: 10,
|
|
45
|
+
completion_tokens: 5,
|
|
46
|
+
total_tokens: 15,
|
|
47
|
+
prompt_cache_tokens: 8,
|
|
48
|
+
});
|
|
49
|
+
expect(normalizeUsage({ input_tokens: 20, output_tokens: 10, input_token_details: { cache_read: 15 } })).toEqual({
|
|
50
|
+
prompt_tokens: 20,
|
|
51
|
+
completion_tokens: 10,
|
|
52
|
+
total_tokens: 30,
|
|
53
|
+
prompt_cache_tokens: 15,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
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 },
|
|
35
92
|
});
|
|
36
93
|
});
|
|
37
94
|
|