plugin-ai-api 1.0.24 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/757.6568d3504ad29352.js +10 -0
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/757.f2bc9cfba07004b0.js +10 -0
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/externalVersion.js +8 -8
  17. package/dist/locale/en-US.json +27 -8
  18. package/dist/locale/vi-VN.json +27 -8
  19. package/dist/locale/zh-CN.json +27 -8
  20. package/dist/server/billing.js +31 -33
  21. package/dist/server/collections/ai-api-config.js +7 -7
  22. package/dist/server/collections/ai-api-group-members.js +62 -0
  23. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  24. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  25. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  26. package/dist/server/collections/ai-api-usage-records.js +2 -0
  27. package/dist/server/middleware/rate-limit.js +7 -6
  28. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  29. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  30. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  31. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  32. package/dist/server/plugin.js +100 -22
  33. package/dist/server/quota-groups.js +108 -0
  34. package/dist/server/resource/ai-api-config.js +5 -3
  35. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  36. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  37. package/dist/server/routes/agent-completions.js +2 -1
  38. package/dist/server/routes/chat-completions.js +121 -42
  39. package/dist/server/routes/completions.js +48 -29
  40. package/dist/server/routes/embeddings.js +2 -1
  41. package/dist/server/routes/models.js +2 -1
  42. package/dist/server/routes/router.js +3 -2
  43. package/dist/server/services/file-processor.js +426 -0
  44. package/dist/server/usage.js +37 -3
  45. package/dist/server/utils/direct-llm-context.js +163 -26
  46. package/dist/server/utils/openai-format.js +21 -2
  47. package/dist/server/utils/rate-limiter.js +1 -1
  48. package/dist/server/utils/request-cache.js +61 -0
  49. package/dist/server/utils/resolve-service.js +2 -1
  50. package/dist/server/utils/user-permissions.js +25 -39
  51. package/dist/server/validation.js +7 -0
  52. package/dist/swagger.js +48 -10
  53. package/package.json +1 -1
  54. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  55. package/src/client/plugin.tsx +5 -16
  56. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  57. package/src/client-v2/locale.ts +3 -1
  58. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  59. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  60. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  61. package/src/client-v2/pages/UsagePage.tsx +9 -0
  62. package/src/client-v2/plugin.tsx +4 -13
  63. package/src/constants.ts +0 -7
  64. package/src/locale/en-US.json +27 -8
  65. package/src/locale/vi-VN.json +27 -8
  66. package/src/locale/zh-CN.json +27 -8
  67. package/src/server/__tests__/billing-quota.test.ts +28 -9
  68. package/src/server/__tests__/direct-llm-context.test.ts +209 -10
  69. package/src/server/__tests__/file-processor.test.ts +225 -0
  70. package/src/server/__tests__/models.test.ts +1 -1
  71. package/src/server/__tests__/openai-format.test.ts +12 -2
  72. package/src/server/__tests__/permission-sync.test.ts +34 -35
  73. package/src/server/__tests__/request-body.test.ts +45 -2
  74. package/src/server/__tests__/usage-groups.test.ts +160 -0
  75. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  76. package/src/server/__tests__/usage-route.test.ts +382 -5
  77. package/src/server/__tests__/usage.test.ts +57 -0
  78. package/src/server/__tests__/user-permissions.test.ts +214 -133
  79. package/src/server/__tests__/validation.test.ts +11 -0
  80. package/src/server/billing.ts +36 -39
  81. package/src/server/collections/ai-api-config.ts +9 -7
  82. package/src/server/collections/ai-api-group-members.ts +41 -0
  83. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  84. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  85. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  86. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  87. package/src/server/collections/ai-api-usage-records.ts +2 -0
  88. package/src/server/index.ts +10 -10
  89. package/src/server/middleware/rate-limit.ts +68 -70
  90. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  91. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  92. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  93. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  94. package/src/server/plugin.ts +121 -30
  95. package/src/server/quota-groups.ts +117 -0
  96. package/src/server/resource/ai-api-config.ts +5 -3
  97. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  98. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  99. package/src/server/routes/agent-completions.ts +2 -1
  100. package/src/server/routes/chat-completions.ts +173 -47
  101. package/src/server/routes/completions.ts +50 -27
  102. package/src/server/routes/embeddings.ts +2 -1
  103. package/src/server/routes/models.ts +4 -3
  104. package/src/server/routes/router.ts +4 -3
  105. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  106. package/src/server/services/file-processor.ts +513 -0
  107. package/src/server/usage.ts +51 -1
  108. package/src/server/utils/direct-llm-context.ts +218 -31
  109. package/src/server/utils/openai-format.ts +25 -2
  110. package/src/server/utils/rate-limiter.ts +83 -83
  111. package/src/server/utils/request-cache.ts +59 -0
  112. package/src/server/utils/resolve-service.ts +83 -82
  113. package/src/server/utils/user-permissions.ts +49 -69
  114. package/src/server/validation.ts +7 -0
  115. package/src/swagger.ts +52 -11
  116. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  117. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  118. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  119. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  120. package/dist/client/902.e74518750f1e4201.js +0 -10
  121. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  122. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  123. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  124. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  125. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  126. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  127. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  128. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  129. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  130. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -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({
@@ -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({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
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({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
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({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
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