plugin-ai-api 1.1.0 → 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.
Files changed (128) hide show
  1. package/README.md +51 -12
  2. package/dist/client/185.c47663fefaeb0e5b.js +10 -0
  3. package/dist/client/562.9012cfd1fa04303d.js +10 -0
  4. package/dist/client/685.b5b1e0a5b825d253.js +10 -0
  5. package/dist/client/97.1bc5103fd9d995a8.js +10 -0
  6. package/dist/client/index.js +1 -1
  7. package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
  8. package/dist/client-v2/562.db2984167250b1be.js +10 -0
  9. package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
  10. package/dist/client-v2/97.96da323832251796.js +10 -0
  11. package/dist/client-v2/index.js +1 -1
  12. package/dist/externalVersion.js +8 -8
  13. package/dist/locale/en-US.json +38 -1
  14. package/dist/locale/vi-VN.json +177 -138
  15. package/dist/locale/zh-CN.json +177 -138
  16. package/dist/server/billing.js +17 -9
  17. package/dist/server/collections/ai-api-model-metadata.js +26 -0
  18. package/dist/server/collections/ai-api-model-prices.js +8 -0
  19. package/dist/server/collections/ai-api-response-records.js +101 -0
  20. package/dist/server/collections/ai-api-usage-records.js +1 -0
  21. package/dist/server/collections/ai-api-virtual-models.js +68 -0
  22. package/dist/server/middleware/response-record-resource.js +66 -0
  23. package/dist/server/middleware/role-permission.js +43 -18
  24. package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
  25. package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
  26. package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
  27. package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
  28. package/dist/server/plugin.js +66 -16
  29. package/dist/server/routes/auth.js +16 -0
  30. package/dist/server/routes/chat-completions.js +38 -6
  31. package/dist/server/routes/completions.js +16 -4
  32. package/dist/server/routes/embeddings.js +34 -5
  33. package/dist/server/routes/models.js +29 -0
  34. package/dist/server/routes/responses.js +530 -0
  35. package/dist/server/routes/router.js +65 -10
  36. package/dist/server/usage.js +26 -4
  37. package/dist/server/utils/direct-llm-context.js +10 -9
  38. package/dist/server/utils/resolve-service.js +24 -0
  39. package/dist/server/utils/response-store.js +138 -0
  40. package/dist/server/utils/responses-format.js +686 -0
  41. package/dist/server/utils/responses-stream.js +330 -0
  42. package/dist/server/utils/virtual-models.js +238 -0
  43. package/dist/server/validation.js +45 -2
  44. package/dist/swagger.js +137 -0
  45. package/package.json +34 -32
  46. package/src/__tests__/locale.test.ts +43 -0
  47. package/src/client/__tests__/settings-registration.test.tsx +1 -0
  48. package/src/client/index.tsx +10 -10
  49. package/src/client/models/index.ts +12 -12
  50. package/src/client/plugin.tsx +9 -1
  51. package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
  52. package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
  53. package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
  54. package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
  55. package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
  56. package/src/client-v2/plugin.tsx +8 -0
  57. package/src/index.ts +11 -11
  58. package/src/locale/en-US.json +38 -1
  59. package/src/locale/vi-VN.json +177 -138
  60. package/src/locale/zh-CN.json +177 -138
  61. package/src/server/__tests__/billing-quota.test.ts +8 -2
  62. package/src/server/__tests__/billing.test.ts +13 -0
  63. package/src/server/__tests__/embeddings.test.ts +184 -0
  64. package/src/server/__tests__/models.test.ts +21 -1
  65. package/src/server/__tests__/response-record-resource.test.ts +50 -0
  66. package/src/server/__tests__/response-store-integration.test.ts +341 -0
  67. package/src/server/__tests__/response-store.test.ts +195 -0
  68. package/src/server/__tests__/responses-contract.test.ts +469 -0
  69. package/src/server/__tests__/responses-format.test.ts +299 -0
  70. package/src/server/__tests__/responses-router.test.ts +182 -0
  71. package/src/server/__tests__/responses-streaming.test.ts +368 -0
  72. package/src/server/__tests__/responses.test.ts +462 -0
  73. package/src/server/__tests__/role-permission.test.ts +139 -0
  74. package/src/server/__tests__/seed-role-permission.test.ts +88 -0
  75. package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
  76. package/src/server/__tests__/usage-groups.test.ts +96 -0
  77. package/src/server/__tests__/usage-route.test.ts +1 -0
  78. package/src/server/__tests__/usage.test.ts +15 -0
  79. package/src/server/__tests__/validation.test.ts +66 -7
  80. package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
  81. package/src/server/billing.ts +29 -6
  82. package/src/server/collections/ai-api-model-metadata.ts +26 -0
  83. package/src/server/collections/ai-api-model-prices.ts +8 -0
  84. package/src/server/collections/ai-api-response-records.ts +77 -0
  85. package/src/server/collections/ai-api-usage-records.ts +1 -0
  86. package/src/server/collections/ai-api-virtual-models.ts +58 -0
  87. package/src/server/middleware/response-record-resource.ts +44 -0
  88. package/src/server/middleware/role-permission.ts +69 -35
  89. package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
  90. package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
  91. package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
  92. package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
  93. package/src/server/plugin.ts +84 -20
  94. package/src/server/resource/ai-api-config.ts +2 -1
  95. package/src/server/routes/agent-completions.ts +3 -0
  96. package/src/server/routes/auth.ts +21 -1
  97. package/src/server/routes/chat-completions.ts +34 -10
  98. package/src/server/routes/completions.ts +16 -4
  99. package/src/server/routes/embeddings.ts +42 -6
  100. package/src/server/routes/models.ts +34 -0
  101. package/src/server/routes/responses.ts +640 -0
  102. package/src/server/routes/router.ts +81 -12
  103. package/src/server/services/__tests__/file-processor.test.ts +1 -0
  104. package/src/server/usage.ts +30 -2
  105. package/src/server/utils/app-observability.ts +1 -1
  106. package/src/server/utils/direct-llm-context.ts +23 -12
  107. package/src/server/utils/openai-format.ts +1 -0
  108. package/src/server/utils/resolve-service.ts +39 -1
  109. package/src/server/utils/response-store.ts +148 -0
  110. package/src/server/utils/responses-format.ts +974 -0
  111. package/src/server/utils/responses-stream.ts +384 -0
  112. package/src/server/utils/virtual-models.ts +320 -0
  113. package/src/server/validation.ts +50 -0
  114. package/src/swagger.ts +139 -0
  115. package/dist/client/562.44b16aad4718b4c7.js +0 -10
  116. package/dist/client/685.ae483e17b6b49c98.js +0 -10
  117. package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
  118. package/dist/client-v2/562.45d5c504433be38b.js +0 -10
  119. package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
  120. package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
  121. package/dist/server/collections/ai-api-user-permissions.js +0 -67
  122. package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
  123. package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
  124. package/dist/server/resource/ai-api-usage-groups.js +0 -168
  125. package/src/server/collections/ai-api-user-permissions.ts +0 -46
  126. package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
  127. package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
  128. package/src/server/resource/ai-api-usage-groups.ts +0 -171
@@ -0,0 +1,589 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import type { Context } from '@nocobase/actions';
11
+ import type { Model } from '@nocobase/database';
12
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import type PluginAiApiServer from '../plugin';
14
+ import { handleChatCompletions } from '../routes/chat-completions';
15
+ import { handleEmbeddings } from '../routes/embeddings';
16
+ import { resolveModelReference, resolveModelString } from '../utils/resolve-service';
17
+ import { detectRequestSignals, listAccessibleVirtualModels } from '../utils/virtual-models';
18
+ import { invalidateGroupAccessCache } from '../utils/user-permissions';
19
+
20
+ vi.mock('../utils/resolve-service', () => ({
21
+ resolveModelString: vi.fn(),
22
+ resolveModelReference: vi.fn(),
23
+ }));
24
+
25
+ interface ModelResult {
26
+ content: string;
27
+ response_metadata?: Record<string, unknown>;
28
+ usage_metadata?: Record<string, unknown>;
29
+ }
30
+
31
+ interface RepositoryOverride {
32
+ findOne?: () => unknown;
33
+ find?: () => unknown;
34
+ }
35
+
36
+ function createContext(
37
+ result: ModelResult,
38
+ requestBody: Record<string, unknown>,
39
+ repositories: Record<string, RepositoryOverride>,
40
+ metadataGet?: (key: string) => unknown,
41
+ ) {
42
+ const model = {
43
+ invoke: vi.fn().mockResolvedValue(result),
44
+ modelKwargs: {},
45
+ bindTools: vi.fn(function (this: unknown) {
46
+ return this;
47
+ }),
48
+ };
49
+ class TestProvider {
50
+ createModel() {
51
+ return model;
52
+ }
53
+ }
54
+
55
+ const getMetadataValue =
56
+ metadataGet ??
57
+ ((key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true));
58
+
59
+ const ctx = {
60
+ app: {
61
+ pm: {
62
+ get: vi.fn().mockReturnValue({
63
+ aiManager: {
64
+ llmProviders: new Map([['test-provider', { provider: TestProvider }]]),
65
+ },
66
+ }),
67
+ },
68
+ },
69
+ db: {
70
+ getRepository: vi.fn((name: string) => {
71
+ if (repositories[name]) return repositories[name];
72
+ if (name === 'aiApiModelMetadata') {
73
+ return {
74
+ findOne: vi.fn().mockResolvedValue({ get: getMetadataValue }),
75
+ find: vi.fn().mockResolvedValue([]),
76
+ };
77
+ }
78
+ if (name === 'aiApiUsageGroups') {
79
+ return {
80
+ findOne: vi.fn().mockResolvedValue({ id: 1, name: 'Default', isDefault: true, allowAllModels: true }),
81
+ };
82
+ }
83
+ return { findOne: vi.fn().mockResolvedValue(null), find: vi.fn().mockResolvedValue([]) };
84
+ }),
85
+ },
86
+ log: { error: vi.fn() },
87
+ request: { body: requestBody },
88
+ state: { currentUser: { id: 1 } } as Record<string, unknown>,
89
+ set: vi.fn(),
90
+ // Minimal req/res mocks so streaming.ts helpers do not touch undefined.
91
+ req: {
92
+ aborted: false,
93
+ once: vi.fn(),
94
+ off: vi.fn(),
95
+ },
96
+ res: {
97
+ writableEnded: false,
98
+ destroyed: false,
99
+ once: vi.fn(),
100
+ off: vi.fn(),
101
+ write: vi.fn(() => true),
102
+ end: vi.fn(),
103
+ },
104
+ } as unknown as Context;
105
+
106
+ return { ctx, model };
107
+ }
108
+
109
+ const VIRTUAL_MODEL_ROW = {
110
+ get: (key: string) => {
111
+ const row: Record<string, unknown> = {
112
+ name: 'auto',
113
+ mode: 'chat',
114
+ enabled: true,
115
+ fallbackModel: 'fallback-svc/fallback-model',
116
+ visionModels: ['vision-svc/vision-model'],
117
+ toolModels: ['tool-svc/tool-model'],
118
+ reasoningModels: ['reasoning-svc/reasoning-model'],
119
+ cheapModels: [],
120
+ generalModels: ['general-svc/general-model'],
121
+ };
122
+ return row[key];
123
+ },
124
+ };
125
+
126
+ describe('AI API virtual model routing (llm mode)', () => {
127
+ beforeEach(() => {
128
+ vi.clearAllMocks();
129
+ invalidateGroupAccessCache();
130
+ // Default: every concrete model resolves successfully.
131
+ const resolveAny = async (_ctx: unknown, modelString: unknown) => {
132
+ const [service, modelId] = String(modelString).split('/');
133
+ if (!service || !modelId) return null;
134
+ return {
135
+ service: { enabled: true, name: service, options: {}, provider: 'test-provider' } as unknown as Model,
136
+ modelId,
137
+ };
138
+ };
139
+ vi.mocked(resolveModelReference).mockImplementation(resolveAny);
140
+ vi.mocked(resolveModelString).mockImplementation(resolveAny);
141
+ });
142
+
143
+ it('skips bucket candidates whose service reference does not resolve (no default-service fallback)', async () => {
144
+ // A typo like "missing-svc/model" must be skipped, not silently reinterpreted as a model id
145
+ // on the default service. Only the valid fallback resolves.
146
+ const fallbackModel = {
147
+ enabled: true,
148
+ name: 'fallback-svc',
149
+ title: 'fallback-svc',
150
+ options: {},
151
+ provider: 'test-provider',
152
+ } as unknown as Model;
153
+ vi.mocked(resolveModelReference).mockImplementation(async (_ctx, reference) => {
154
+ const [service, modelId] = String(reference).split('/');
155
+ if (service === 'missing-svc') return null;
156
+ if (!service || !modelId) return null;
157
+ if (service === 'fallback-svc') return { service: fallbackModel, modelId };
158
+ return {
159
+ service: {
160
+ enabled: true,
161
+ name: service,
162
+ title: service,
163
+ options: {},
164
+ provider: 'test-provider',
165
+ } as unknown as Model,
166
+ modelId,
167
+ };
168
+ });
169
+ const row = {
170
+ get: (key: string) =>
171
+ (
172
+ ({
173
+ name: 'auto',
174
+ mode: 'chat',
175
+ enabled: true,
176
+ fallbackModel: 'fallback-svc/fallback-model',
177
+ visionModels: ['missing-svc/typo-model'],
178
+ toolModels: [],
179
+ reasoningModels: [],
180
+ cheapModels: [],
181
+ generalModels: [],
182
+ }) as Record<string, unknown>
183
+ )[key],
184
+ };
185
+ const { ctx } = createContext(
186
+ { content: 'fallback used' },
187
+ {
188
+ model: 'auto',
189
+ messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/x.png' } }] }],
190
+ },
191
+ {
192
+ aiApiVirtualModels: { findOne: () => Promise.resolve(row), find: () => Promise.resolve([row]) },
193
+ aiApiUsageGroups: {
194
+ findOne: () =>
195
+ Promise.resolve({
196
+ id: 1,
197
+ name: 'Default',
198
+ isDefault: true,
199
+ allowedLlmServices: ['fallback-svc'],
200
+ allowAllModels: true,
201
+ }),
202
+ },
203
+ },
204
+ );
205
+
206
+ await handleChatCompletions(ctx, {} as PluginAiApiServer).catch((e) => {
207
+ throw new Error(`handleChatCompletions threw: ${e}\nctx.body=${JSON.stringify(ctx.body)}`);
208
+ });
209
+
210
+ if (ctx.status !== 200) {
211
+ throw new Error(`expected 200, got ${ctx.status}: ${JSON.stringify(ctx.body)}`);
212
+ }
213
+ expect(ctx.state.aiApiRoutingReason).toBe('fallback');
214
+ // The typo candidate was attempted strictly and returned null — not resolved on another service.
215
+ expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'missing-svc/typo-model');
216
+ // Routing fell through to the valid fallback.
217
+ expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'fallback-svc/fallback-model');
218
+ });
219
+
220
+ it('skips bucket candidates with typo service references (direct resolution)', async () => {
221
+ // Pure resolution check without the HTTP handler, to isolate routing from request plumbing.
222
+ const { resolveVirtualModel } = await import('../utils/virtual-models');
223
+ const fallbackModel = {
224
+ enabled: true,
225
+ name: 'fallback-svc',
226
+ title: 'fallback-svc',
227
+ options: {},
228
+ provider: 'test-provider',
229
+ } as unknown as Model;
230
+ vi.mocked(resolveModelReference).mockImplementation(async (_ctx, reference) => {
231
+ const [service, modelId] = String(reference).split('/');
232
+ if (service === 'missing-svc') return null;
233
+ if (!service || !modelId) return null;
234
+ if (service === 'fallback-svc') return { service: fallbackModel, modelId };
235
+ return {
236
+ service: {
237
+ enabled: true,
238
+ name: service,
239
+ title: service,
240
+ options: {},
241
+ provider: 'test-provider',
242
+ } as unknown as Model,
243
+ modelId,
244
+ };
245
+ });
246
+ const row = {
247
+ get: (key: string) =>
248
+ (
249
+ ({
250
+ name: 'auto',
251
+ mode: 'chat',
252
+ enabled: true,
253
+ fallbackModel: 'fallback-svc/fallback-model',
254
+ visionModels: ['missing-svc/typo-model'],
255
+ toolModels: [],
256
+ reasoningModels: [],
257
+ cheapModels: [],
258
+ generalModels: [],
259
+ }) as Record<string, unknown>
260
+ )[key],
261
+ };
262
+ const { ctx } = createContext(
263
+ { content: 'fallback used' },
264
+ {
265
+ model: 'auto',
266
+ messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/x.png' } }] }],
267
+ },
268
+ {
269
+ aiApiVirtualModels: { findOne: () => Promise.resolve(row), find: () => Promise.resolve([row]) },
270
+ aiApiUsageGroups: {
271
+ findOne: () =>
272
+ Promise.resolve({
273
+ id: 1,
274
+ name: 'Default',
275
+ isDefault: true,
276
+ allowedLlmServices: ['fallback-svc'],
277
+ allowAllModels: true,
278
+ }),
279
+ },
280
+ },
281
+ );
282
+
283
+ const result = await resolveVirtualModel(ctx, 'auto', ctx.request.body as Record<string, unknown>, 'chat');
284
+
285
+ expect(result?.status).toBe('resolved');
286
+ expect(result?.reason).toBe('fallback');
287
+ expect((result as { resolved?: { modelId: string } }).resolved?.modelId).toBe('fallback-model');
288
+ expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'missing-svc/typo-model');
289
+ });
290
+
291
+ it('derives the general bucket from every metadata tier ordered by sortOrder when no bucket is configured', async () => {
292
+ // Characterization: empty generalModels/cheapModels means the general bucket is derived
293
+ // from model metadata across ALL reasoningTiers, ordered by sortOrder. A plain request
294
+ // (no capability signals) is served by the lowest-sortOrder row even if that row is a
295
+ // reasoning-tier model — the general bucket is a catch-all, not a tier filter.
296
+ const { resolveVirtualModel } = await import('../utils/virtual-models');
297
+ const metadataRow = (llmService: string, model: string, reasoningTier: string, sortOrder: number) => ({
298
+ get: (key: string) => ({ llmService, model, reasoningTier, sortOrder })[key],
299
+ });
300
+ const row = {
301
+ get: (key: string) =>
302
+ (
303
+ ({
304
+ name: 'auto',
305
+ mode: 'chat',
306
+ enabled: true,
307
+ fallbackModel: 'svc/fallback',
308
+ visionModels: [],
309
+ toolModels: [],
310
+ reasoningModels: [],
311
+ cheapModels: [],
312
+ generalModels: [],
313
+ }) as Record<string, unknown>
314
+ )[key],
315
+ };
316
+ const { ctx } = createContext(
317
+ { content: 'general answer' },
318
+ { model: 'auto', messages: [{ role: 'user', content: 'Hello' }], stream: false },
319
+ {
320
+ aiApiVirtualModels: { findOne: () => Promise.resolve(row), find: () => Promise.resolve([row]) },
321
+ aiApiModelMetadata: {
322
+ findOne: () => Promise.resolve(null),
323
+ find: () =>
324
+ Promise.resolve([
325
+ metadataRow('svc', 'reasoning-a', 'reasoning', 1),
326
+ metadataRow('svc', 'general-b', 'general', 2),
327
+ ]),
328
+ },
329
+ },
330
+ );
331
+
332
+ const result = await resolveVirtualModel(ctx, 'auto', ctx.request.body as Record<string, unknown>, 'chat');
333
+
334
+ expect(result?.status).toBe('resolved');
335
+ expect(result?.reason).toBe('general');
336
+ expect((result as { resolved?: { modelId: string } }).resolved?.modelId).toBe('reasoning-a');
337
+ });
338
+
339
+ it('routes a vision request to the first permitted vision candidate', async () => {
340
+ const { ctx } = createContext(
341
+ { content: 'I see an image.' },
342
+ {
343
+ model: 'auto',
344
+ messages: [
345
+ {
346
+ role: 'user',
347
+ content: [
348
+ { type: 'text', text: 'Describe this' },
349
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,aGVsbG8=' } },
350
+ ],
351
+ },
352
+ ],
353
+ stream: false,
354
+ },
355
+ { aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
356
+ );
357
+
358
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
359
+
360
+ if (ctx.status !== 200) throw new Error(`vision test expected 200, got ${ctx.status}: ${JSON.stringify(ctx.body)}`);
361
+ expect(ctx.state.aiApiVirtualModel).toBe('auto');
362
+ expect(ctx.state.aiApiRoutingReason).toBe('vision');
363
+ expect(ctx.state.aiApiLlmBilling).toMatchObject({
364
+ resolution: { service: 'vision-svc', model: 'vision-model' },
365
+ });
366
+ // Response must carry the resolved concrete model, not the alias.
367
+ expect((ctx.body as { model: string }).model).toBe('vision-svc/vision-model');
368
+ });
369
+
370
+ it('routes a tool-calling request to the tool bucket', async () => {
371
+ const { ctx } = createContext(
372
+ { content: 'ok' },
373
+ {
374
+ model: 'auto',
375
+ messages: [{ role: 'user', content: 'Call a tool' }],
376
+ tools: [{ type: 'function', function: { name: 'x' } }],
377
+ stream: false,
378
+ },
379
+ { aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
380
+ );
381
+
382
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
383
+
384
+ expect(ctx.status).toBe(200);
385
+ expect(ctx.state.aiApiRoutingReason).toBe('tools');
386
+ expect(ctx.state.aiApiLlmBilling).toMatchObject({
387
+ resolution: { service: 'tool-svc', model: 'tool-model' },
388
+ });
389
+ });
390
+
391
+ it('treats file blocks as vision input', () => {
392
+ expect(
393
+ detectRequestSignals({
394
+ messages: [
395
+ {
396
+ role: 'user',
397
+ content: [{ type: 'file', file: { file_data: 'data:application/pdf;base64,YQ==' } }],
398
+ },
399
+ ],
400
+ }),
401
+ ).toMatchObject({ hasImage: true });
402
+ });
403
+
404
+ it('routes an explicit reasoning request to the reasoning bucket', async () => {
405
+ const { ctx } = createContext(
406
+ { content: 'reasoned answer' },
407
+ {
408
+ model: 'auto',
409
+ messages: [{ role: 'user', content: 'Think carefully' }],
410
+ reasoning_effort: 'high',
411
+ stream: false,
412
+ },
413
+ { aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
414
+ );
415
+
416
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
417
+
418
+ expect(ctx.status).toBe(200);
419
+ expect(ctx.state.aiApiRoutingReason).toBe('reasoning');
420
+ expect(ctx.state.aiApiLlmBilling).toMatchObject({
421
+ resolution: { service: 'reasoning-svc', model: 'reasoning-model' },
422
+ });
423
+ });
424
+
425
+ it('does not resolve a chat alias for the embeddings endpoint', async () => {
426
+ const { ctx } = createContext(
427
+ { content: '' },
428
+ { model: 'auto', input: 'Embed this' },
429
+ { aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
430
+ );
431
+
432
+ await handleEmbeddings(ctx, {} as PluginAiApiServer);
433
+
434
+ expect(ctx.status).toBe(404);
435
+ expect(ctx.body).toMatchObject({ error: { code: 'model_not_found' } });
436
+ expect(resolveModelReference).not.toHaveBeenCalled();
437
+ });
438
+
439
+ it('does not resolve an embedding alias for the chat endpoint', async () => {
440
+ const embeddingRow = {
441
+ get: (key: string) => (key === 'mode' ? 'embedding' : VIRTUAL_MODEL_ROW.get(key)),
442
+ };
443
+ const { ctx } = createContext(
444
+ { content: '' },
445
+ { model: 'auto', messages: [{ role: 'user', content: 'Hello' }] },
446
+ { aiApiVirtualModels: { findOne: () => Promise.resolve(embeddingRow) } },
447
+ );
448
+
449
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
450
+
451
+ expect(ctx.status).toBe(404);
452
+ expect(ctx.body).toMatchObject({ error: { code: 'model_not_found' } });
453
+ expect(resolveModelReference).not.toHaveBeenCalled();
454
+ });
455
+
456
+ it('resolves an embedding alias directly to its fallback without using chat buckets', async () => {
457
+ const embeddingRow = {
458
+ get: (key: string) => (key === 'mode' ? 'embedding' : VIRTUAL_MODEL_ROW.get(key)),
459
+ };
460
+ const embeddingModel = { embedDocuments: vi.fn().mockResolvedValue([[0.1, 0.2]]) };
461
+ class EmbeddingProvider {
462
+ createEmbedding() {
463
+ return embeddingModel;
464
+ }
465
+ }
466
+ const { ctx } = createContext(
467
+ { content: '' },
468
+ { model: 'auto', input: 'Embed this' },
469
+ { aiApiVirtualModels: { findOne: () => Promise.resolve(embeddingRow) } },
470
+ );
471
+ vi.mocked(ctx.app.pm.get).mockReturnValue({
472
+ aiManager: { llmProviders: new Map([['test-provider', { embedding: EmbeddingProvider }]]) },
473
+ });
474
+
475
+ await handleEmbeddings(ctx, {} as PluginAiApiServer);
476
+
477
+ expect(ctx.status).toBe(200);
478
+ expect(ctx.state.aiApiRoutingReason).toBe('fallback');
479
+ expect(ctx.body).toMatchObject({ model: 'fallback-svc/fallback-model' });
480
+ expect(resolveModelReference).toHaveBeenCalledTimes(1);
481
+ expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'fallback-svc/fallback-model');
482
+ });
483
+
484
+ it('falls back to the configured fallback when no bucket candidate is permitted', async () => {
485
+ // Only the fallback model resolves; every other candidate returns null.
486
+ vi.mocked(resolveModelReference).mockImplementation(async (_ctx, modelString) => {
487
+ if (String(modelString) === 'fallback-svc/fallback-model') {
488
+ return {
489
+ service: { enabled: true, name: 'fallback-svc', options: {}, provider: 'test-provider' } as unknown as Model,
490
+ modelId: 'fallback-model',
491
+ };
492
+ }
493
+ return null;
494
+ });
495
+
496
+ const { ctx } = createContext(
497
+ { content: 'fallback ok' },
498
+ {
499
+ model: 'auto',
500
+ messages: [{ role: 'user', content: 'Hello' }],
501
+ stream: false,
502
+ },
503
+ { aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
504
+ );
505
+
506
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
507
+
508
+ expect(ctx.status).toBe(200);
509
+ expect(ctx.state.aiApiRoutingReason).toBe('fallback');
510
+ expect(ctx.state.aiApiLlmBilling).toMatchObject({
511
+ resolution: { service: 'fallback-svc', model: 'fallback-model' },
512
+ });
513
+ });
514
+
515
+ it('does not bypass the caller scope through the fallback model', async () => {
516
+ const restrictedGroup = {
517
+ id: 2,
518
+ name: 'Restricted',
519
+ isDefault: true,
520
+ allowedLlmServices: ['other-svc'],
521
+ allowAllModels: true,
522
+ };
523
+ const { ctx } = createContext(
524
+ { content: '' },
525
+ { model: 'auto', messages: [{ role: 'user', content: 'Hello' }] },
526
+ {
527
+ aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) },
528
+ aiApiUsageGroups: { findOne: () => Promise.resolve(restrictedGroup) },
529
+ },
530
+ );
531
+
532
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
533
+
534
+ expect(ctx.status).toBe(403);
535
+ expect(ctx.body).toMatchObject({ error: { code: 'model_not_available' } });
536
+ expect(ctx.state.aiApiLlmBilling).toBeUndefined();
537
+ });
538
+
539
+ it('passes through non-virtual models unchanged', async () => {
540
+ const { ctx } = createContext(
541
+ { content: 'direct' },
542
+ {
543
+ model: 'direct-svc/direct-model',
544
+ messages: [{ role: 'user', content: 'Hi' }],
545
+ stream: false,
546
+ },
547
+ {
548
+ aiApiVirtualModels: { findOne: () => Promise.resolve(null) },
549
+ aiApiUsageGroups: {
550
+ findOne: () =>
551
+ Promise.resolve({
552
+ id: 1,
553
+ name: 'Default',
554
+ isDefault: true,
555
+ allowedLlmServices: ['direct-svc'],
556
+ allowAllModels: true,
557
+ }),
558
+ },
559
+ },
560
+ );
561
+
562
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
563
+
564
+ if (ctx.status !== 200)
565
+ throw new Error(`non-virtual test expected 200, got ${ctx.status}: ${JSON.stringify(ctx.body)}`);
566
+ expect(ctx.state.aiApiVirtualModel).toBeUndefined();
567
+ expect(ctx.state.aiApiRoutingReason).toBeUndefined();
568
+ expect(ctx.state.aiApiLlmBilling).toMatchObject({
569
+ resolution: { service: 'direct-svc', model: 'direct-model' },
570
+ });
571
+ });
572
+
573
+ it('does not expose an alias whose fallback model is outside the caller scope', async () => {
574
+ const { ctx } = createContext(
575
+ { content: '' },
576
+ {},
577
+ { aiApiVirtualModels: { find: () => Promise.resolve([VIRTUAL_MODEL_ROW]) } },
578
+ );
579
+ const scope = {
580
+ groupId: 1,
581
+ allowedServices: ['other-svc'],
582
+ allowAllModels: true,
583
+ allowedModels: new Set<string>(),
584
+ lookupFailed: false,
585
+ };
586
+
587
+ await expect(listAccessibleVirtualModels(ctx, scope, [])).resolves.toEqual([]);
588
+ });
589
+ });
@@ -24,6 +24,7 @@ export interface PriceSnapshot {
24
24
  id: string | number | bigint;
25
25
  currency: string;
26
26
  inputPricePerMillionTokens: string;
27
+ cacheInputPricePerMillionTokens: string;
27
28
  outputPricePerMillionTokens: string;
28
29
  fixedCostPerRequest: string;
29
30
  }
@@ -60,6 +61,7 @@ export interface BillingFinalization {
60
61
  groupId?: string | number | bigint;
61
62
  quotaMode?: QuotaMode;
62
63
  inputPricePerMillionTokens?: string;
64
+ cacheInputPricePerMillionTokens?: string;
63
65
  outputPricePerMillionTokens?: string;
64
66
  fixedCostPerRequest?: string;
65
67
  }
@@ -112,9 +114,20 @@ function divideRounded(value: bigint, divisor: bigint): bigint {
112
114
  return (value + divisor / 2n) / divisor;
113
115
  }
114
116
 
115
- function calculateCostUnits(inputTokens: number, outputTokens: number, price: PriceSnapshot): bigint {
117
+ function calculateCostUnits(
118
+ inputTokens: number,
119
+ outputTokens: number,
120
+ promptCacheTokens: number,
121
+ price: PriceSnapshot,
122
+ ): bigint {
123
+ const cachedInputTokens = Math.min(Math.max(promptCacheTokens, 0), inputTokens);
124
+ const uncachedInputTokens = inputTokens - cachedInputTokens;
116
125
  const input = divideRounded(
117
- BigInt(inputTokens) * decimalUnits(price.inputPricePerMillionTokens, PRICE_SCALE),
126
+ BigInt(uncachedInputTokens) * decimalUnits(price.inputPricePerMillionTokens, PRICE_SCALE),
127
+ PRICE_TO_COST_DIVISOR,
128
+ );
129
+ const cacheInput = divideRounded(
130
+ BigInt(cachedInputTokens) * decimalUnits(price.cacheInputPricePerMillionTokens, PRICE_SCALE),
118
131
  PRICE_TO_COST_DIVISOR,
119
132
  );
120
133
  const output = divideRounded(
@@ -122,11 +135,16 @@ function calculateCostUnits(inputTokens: number, outputTokens: number, price: Pr
122
135
  PRICE_TO_COST_DIVISOR,
123
136
  );
124
137
  const fixed = divideRounded(decimalUnits(price.fixedCostPerRequest, PRICE_SCALE), 100n);
125
- return input + output + fixed;
138
+ return input + cacheInput + output + fixed;
126
139
  }
127
140
 
128
- export function calculateLlmCost(inputTokens: number, outputTokens: number, price: PriceSnapshot): string {
129
- return formatUnits(calculateCostUnits(inputTokens, outputTokens, price), COST_SCALE);
141
+ export function calculateLlmCost(
142
+ inputTokens: number,
143
+ outputTokens: number,
144
+ price: PriceSnapshot,
145
+ promptCacheTokens = 0,
146
+ ): string {
147
+ return formatUnits(calculateCostUnits(inputTokens, outputTokens, promptCacheTokens, price), COST_SCALE);
130
148
  }
131
149
 
132
150
  function normalizePositiveInteger(value: unknown, fallback: number): number {
@@ -184,6 +202,7 @@ async function findPrice(
184
202
  id: valueOf(price, 'id'),
185
203
  currency: valueOf<string>(price, 'currency'),
186
204
  inputPricePerMillionTokens: decimalString(valueOf(price, 'inputPricePerMillionTokens'), PRICE_SCALE),
205
+ cacheInputPricePerMillionTokens: decimalString(valueOf(price, 'cacheInputPricePerMillionTokens'), PRICE_SCALE),
187
206
  outputPricePerMillionTokens: decimalString(valueOf(price, 'outputPricePerMillionTokens'), PRICE_SCALE),
188
207
  fixedCostPerRequest: decimalString(valueOf(price, 'fixedCostPerRequest'), PRICE_SCALE),
189
208
  };
@@ -325,7 +344,10 @@ export async function finalizeLlmBilling(
325
344
  costStatus = billing.price ? 'usage_unavailable' : 'unpriced';
326
345
  }
327
346
 
328
- const cost = numbers && billing.price ? calculateLlmCost(numbers.input, numbers.output, billing.price) : undefined;
347
+ const cost =
348
+ numbers && billing.price
349
+ ? calculateLlmCost(numbers.input, numbers.output, billing.price, providerUsage?.prompt_cache_tokens ?? 0)
350
+ : undefined;
329
351
  const reservation = billing.reservation;
330
352
  if (reservation) {
331
353
  const Bucket = ctx.db.getModel('aiApiGroupQuotaBuckets');
@@ -378,6 +400,7 @@ export async function finalizeLlmBilling(
378
400
  groupId: reservation?.groupId,
379
401
  quotaMode: reservation?.quotaMode,
380
402
  inputPricePerMillionTokens: billing.price?.inputPricePerMillionTokens,
403
+ cacheInputPricePerMillionTokens: billing.price?.cacheInputPricePerMillionTokens,
381
404
  outputPricePerMillionTokens: billing.price?.outputPricePerMillionTokens,
382
405
  fixedCostPerRequest: billing.price?.fixedCostPerRequest,
383
406
  };