plugin-ai-api 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) 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/index.js +1 -1
  6. package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
  7. package/dist/client-v2/562.db2984167250b1be.js +10 -0
  8. package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
  9. package/dist/client-v2/index.js +1 -1
  10. package/dist/externalVersion.js +8 -8
  11. package/dist/locale/en-US.json +175 -139
  12. package/dist/locale/vi-VN.json +40 -2
  13. package/dist/locale/zh-CN.json +40 -2
  14. package/dist/server/collections/ai-api-model-metadata.js +26 -0
  15. package/dist/server/collections/ai-api-response-records.js +101 -0
  16. package/dist/server/collections/ai-api-virtual-models.js +68 -0
  17. package/dist/server/middleware/response-record-resource.js +66 -0
  18. package/dist/server/middleware/role-permission.js +43 -18
  19. package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
  20. package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
  21. package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
  22. package/dist/server/plugin.js +66 -16
  23. package/dist/server/routes/chat-completions.js +38 -6
  24. package/dist/server/routes/completions.js +16 -4
  25. package/dist/server/routes/embeddings.js +25 -6
  26. package/dist/server/routes/models.js +29 -0
  27. package/dist/server/routes/responses.js +530 -0
  28. package/dist/server/routes/router.js +65 -10
  29. package/dist/server/usage.js +25 -4
  30. package/dist/server/utils/direct-llm-context.js +1 -1
  31. package/dist/server/utils/resolve-service.js +24 -0
  32. package/dist/server/utils/response-store.js +138 -0
  33. package/dist/server/utils/responses-format.js +686 -0
  34. package/dist/server/utils/responses-stream.js +330 -0
  35. package/dist/server/utils/virtual-models.js +238 -0
  36. package/dist/server/validation.js +44 -2
  37. package/dist/swagger.js +137 -0
  38. package/package.json +34 -32
  39. package/src/__tests__/locale.test.ts +43 -0
  40. package/src/client/__tests__/settings-registration.test.tsx +1 -0
  41. package/src/client/plugin.tsx +9 -1
  42. package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
  44. package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
  45. package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
  46. package/src/client-v2/plugin.tsx +8 -0
  47. package/src/locale/en-US.json +175 -139
  48. package/src/locale/vi-VN.json +40 -2
  49. package/src/locale/zh-CN.json +40 -2
  50. package/src/server/__tests__/embeddings.test.ts +184 -0
  51. package/src/server/__tests__/models.test.ts +21 -1
  52. package/src/server/__tests__/response-record-resource.test.ts +50 -0
  53. package/src/server/__tests__/response-store-integration.test.ts +341 -0
  54. package/src/server/__tests__/response-store.test.ts +195 -0
  55. package/src/server/__tests__/responses-contract.test.ts +469 -0
  56. package/src/server/__tests__/responses-format.test.ts +299 -0
  57. package/src/server/__tests__/responses-router.test.ts +182 -0
  58. package/src/server/__tests__/responses-streaming.test.ts +368 -0
  59. package/src/server/__tests__/responses.test.ts +462 -0
  60. package/src/server/__tests__/role-permission.test.ts +139 -0
  61. package/src/server/__tests__/seed-role-permission.test.ts +88 -0
  62. package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
  63. package/src/server/__tests__/usage-groups.test.ts +96 -0
  64. package/src/server/__tests__/usage-route.test.ts +1 -0
  65. package/src/server/__tests__/usage.test.ts +14 -0
  66. package/src/server/__tests__/validation.test.ts +66 -7
  67. package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
  68. package/src/server/collections/ai-api-model-metadata.ts +26 -0
  69. package/src/server/collections/ai-api-response-records.ts +77 -0
  70. package/src/server/collections/ai-api-virtual-models.ts +58 -0
  71. package/src/server/middleware/response-record-resource.ts +44 -0
  72. package/src/server/middleware/role-permission.ts +69 -35
  73. package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
  74. package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
  75. package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
  76. package/src/server/plugin.ts +84 -20
  77. package/src/server/resource/ai-api-config.ts +2 -1
  78. package/src/server/routes/agent-completions.ts +3 -0
  79. package/src/server/routes/chat-completions.ts +34 -10
  80. package/src/server/routes/completions.ts +16 -4
  81. package/src/server/routes/embeddings.ts +32 -10
  82. package/src/server/routes/models.ts +34 -0
  83. package/src/server/routes/responses.ts +640 -0
  84. package/src/server/routes/router.ts +81 -12
  85. package/src/server/services/__tests__/file-processor.test.ts +1 -0
  86. package/src/server/usage.ts +29 -2
  87. package/src/server/utils/app-observability.ts +1 -1
  88. package/src/server/utils/direct-llm-context.ts +2 -1
  89. package/src/server/utils/openai-format.ts +1 -0
  90. package/src/server/utils/resolve-service.ts +39 -1
  91. package/src/server/utils/response-store.ts +148 -0
  92. package/src/server/utils/responses-format.ts +974 -0
  93. package/src/server/utils/responses-stream.ts +384 -0
  94. package/src/server/utils/virtual-models.ts +320 -0
  95. package/src/server/validation.ts +49 -0
  96. package/src/swagger.ts +139 -0
  97. package/dist/client/562.44b16aad4718b4c7.js +0 -10
  98. package/dist/client/685.ae483e17b6b49c98.js +0 -10
  99. package/dist/client-v2/562.45d5c504433be38b.js +0 -10
  100. package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
  101. package/dist/server/collections/ai-api-user-permissions.js +0 -67
  102. package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
  103. package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
  104. package/dist/server/resource/ai-api-usage-groups.js +0 -168
  105. package/src/server/collections/ai-api-user-permissions.ts +0 -46
  106. package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
  107. package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
  108. 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
+ });
@@ -62,6 +62,32 @@ export default defineCollection({
62
62
  comment:
63
63
  'Initial system prompt prepended as the first system message of every request for this model. Never replaces the client system prompt.',
64
64
  },
65
+ {
66
+ name: 'supportsVision',
67
+ type: 'boolean',
68
+ defaultValue: false,
69
+ index: true,
70
+ comment: 'Whether the model accepts image content blocks. Used by virtual-model routing.',
71
+ },
72
+ {
73
+ name: 'supportsToolCalling',
74
+ type: 'boolean',
75
+ defaultValue: true,
76
+ index: true,
77
+ comment: 'Whether the model accepts tools / tool_choice. Used by virtual-model routing.',
78
+ },
79
+ {
80
+ name: 'reasoningTier',
81
+ type: 'string',
82
+ defaultValue: 'general',
83
+ comment: 'Cost/capability tier for routing: cheap | general | reasoning.',
84
+ },
85
+ {
86
+ name: 'sortOrder',
87
+ type: 'integer',
88
+ defaultValue: 0,
89
+ comment: 'Ascending priority used when deriving a routing bucket from metadata (lower = preferred).',
90
+ },
65
91
  {
66
92
  name: 'enabled',
67
93
  type: 'boolean',
@@ -0,0 +1,77 @@
1
+ import { defineCollection } from '@nocobase/database';
2
+
3
+ /**
4
+ * Stores OpenAI Responses API responses for conversation state management.
5
+ * Enables `previous_response_id` chaining and `store=true` persistence.
6
+ *
7
+ * Records are automatically expired after 30 days (matching OpenAI's retention).
8
+ */
9
+ export default defineCollection({
10
+ name: 'aiApiResponseRecords',
11
+ dataCategory: 'runtime',
12
+ hidden: true,
13
+ autoGenId: true,
14
+ fields: [
15
+ {
16
+ name: 'responseId',
17
+ type: 'string',
18
+ unique: true,
19
+ index: true,
20
+ comment: 'Public response ID (resp_xxx) exposed to clients',
21
+ },
22
+ {
23
+ name: 'userId',
24
+ type: 'bigInt',
25
+ allowNull: false,
26
+ index: true,
27
+ comment: 'Owner of this response chain',
28
+ },
29
+ {
30
+ name: 'user',
31
+ type: 'belongsTo',
32
+ target: 'users',
33
+ targetKey: 'id',
34
+ foreignKey: 'userId',
35
+ constraints: false,
36
+ },
37
+ {
38
+ name: 'model',
39
+ type: 'string',
40
+ allowNull: false,
41
+ index: true,
42
+ comment: 'Model used for this response (service/model format)',
43
+ },
44
+ {
45
+ name: 'input',
46
+ type: 'jsonb',
47
+ allowNull: false,
48
+ comment: 'Original Responses API input (string or array)',
49
+ },
50
+ {
51
+ name: 'output',
52
+ type: 'jsonb',
53
+ allowNull: false,
54
+ comment: 'Complete ResponseObject as returned to the client',
55
+ },
56
+ {
57
+ name: 'previousResponseId',
58
+ type: 'string',
59
+ allowNull: true,
60
+ index: true,
61
+ comment: 'Links to the previous response in this conversation chain',
62
+ },
63
+ {
64
+ name: 'metadata',
65
+ type: 'jsonb',
66
+ allowNull: true,
67
+ comment: 'Client-provided metadata from the request',
68
+ },
69
+ {
70
+ name: 'expiresAt',
71
+ type: 'datetimeTz',
72
+ allowNull: false,
73
+ index: true,
74
+ comment: 'Auto-delete after 30 days (OpenAI retention policy)',
75
+ },
76
+ ],
77
+ });