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
@@ -12,11 +12,12 @@ import type { Transactionable } from '@nocobase/database';
12
12
  import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
13
13
  import aiApiConfigResource from './resource/ai-api-config';
14
14
  import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
15
- import aiApiUsageGroupsResource from './resource/ai-api-usage-groups';
16
15
  import { RateLimiter } from './utils/rate-limiter';
17
16
  import { invalidateRolePermissionCache } from './middleware/role-permission';
17
+ import { blockResponseRecordResource } from './middleware/response-record-resource';
18
18
  import { invalidateGroupAccessCache } from './utils/user-permissions';
19
- import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
19
+ import { cleanupExpiredResponseRecords } from './utils/response-store';
20
+ import { validateModelPrice, validateModelMetadata, validateQuotaPolicy, validateVirtualModel } from './validation';
20
21
  import { AI_API_ACL_SNIPPET } from '../constants';
21
22
  import {
22
23
  FileProcessorService,
@@ -52,6 +53,7 @@ export class PluginAiApiServer extends Plugin {
52
53
  fileProcessorService = new FileProcessorService();
53
54
 
54
55
  private gcInterval: NodeJS.Timeout | null = null;
56
+ private responseCleanupInterval: NodeJS.Timeout | null = null;
55
57
 
56
58
  async afterAdd() {}
57
59
 
@@ -62,6 +64,9 @@ export class PluginAiApiServer extends Plugin {
62
64
  this.app.db.on('aiApiModelMetadata.beforeSave', (model) => {
63
65
  validateModelMetadata(model);
64
66
  });
67
+ this.app.db.on('aiApiVirtualModels.beforeSave', (model) => {
68
+ validateVirtualModel(model);
69
+ });
65
70
  this.app.db.on('aiApiUsageGroups.beforeSave', async (model, options) => {
66
71
  validateQuotaPolicy(model);
67
72
  // The partial unique index on isDefault is not supported on MySQL,
@@ -82,6 +87,19 @@ export class PluginAiApiServer extends Plugin {
82
87
  this.app.db.on('aiApiGroupMembers.beforeSave', async (model, options) => {
83
88
  const userId = model.get('userId');
84
89
  if (!userId) return;
90
+ // The default group is a fallback, not a membership target: an explicit row would pin the
91
+ // user there and make them invisible to any unassigned-users filter. Reject it here so the
92
+ // rule holds whether the row comes from a custom action or a plain aiApiGroupMembers:create.
93
+ const targetGroupId = model.get('groupId');
94
+ if (targetGroupId) {
95
+ const targetGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
96
+ filterByTk: targetGroupId,
97
+ transaction: options?.transaction,
98
+ });
99
+ if (targetGroup?.get('isDefault')) {
100
+ throw new Error('Cannot add members to the default group: users without another group use it automatically.');
101
+ }
102
+ }
85
103
  const existing = await this.db.getRepository('aiApiGroupMembers').findOne({
86
104
  filter: { userId },
87
105
  transaction: options?.transaction,
@@ -95,24 +113,16 @@ export class PluginAiApiServer extends Plugin {
95
113
  if (model.get('isDefault')) {
96
114
  throw new Error('The default usage group cannot be deleted.');
97
115
  }
116
+ // Drop membership rows so the affected users fall back to the default group
117
+ // implicitly. Rows are destroyed one by one to keep the repository-level
118
+ // destroy events firing for any listener that tracks membership changes.
98
119
  const members = await this.db.getRepository('aiApiGroupMembers').find({
99
120
  filter: { groupId: model.get('id') },
100
121
  transaction: options?.transaction,
101
122
  });
102
- if (members.length === 0) return;
103
-
104
- const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
105
- filter: { isDefault: true },
106
- transaction: options?.transaction,
107
- });
108
- if (!defaultGroup) {
109
- throw new Error('Default usage group is missing; cannot reassign members.');
110
- }
111
-
112
123
  for (const member of members) {
113
- await this.db.getRepository('aiApiGroupMembers').update({
124
+ await this.db.getRepository('aiApiGroupMembers').destroy({
114
125
  filterByTk: member.get('id'),
115
- values: { groupId: defaultGroup.get('id') },
116
126
  transaction: options?.transaction,
117
127
  });
118
128
  }
@@ -151,13 +161,20 @@ export class PluginAiApiServer extends Plugin {
151
161
  // 2. Register admin config resource
152
162
  this.app.resourceManager.define(aiApiConfigResource);
153
163
  this.app.resourceManager.define(aiApiUsageMonitorResource);
154
- this.app.resourceManager.define(aiApiUsageGroupsResource);
155
164
 
156
- this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
157
- invalidateRolePermissionCache(model.get('roleName'));
165
+ // Stored prompts are only exposed through the owner-scoped OpenAI routes. This
166
+ // explicit policy also blocks root, whose generic NocoBase ACL bypass is unconditional.
167
+ this.app.resourceManager.use(blockResponseRecordResource(), {
168
+ tag: 'aiApiResponseRecordsPrivate',
169
+ after: 'auth',
170
+ before: 'acl',
158
171
  });
159
- this.app.db.on('aiApiRolePermissions.afterDestroy', (model) => {
160
- invalidateRolePermissionCache(model.get('roleName'));
172
+
173
+ this.app.db.on('aiApiRolePermissions.afterSave', (model, options) => {
174
+ this.invalidateRolePermissionSync(model.get('roleName'), options?.transaction);
175
+ });
176
+ this.app.db.on('aiApiRolePermissions.afterDestroy', (model, options) => {
177
+ this.invalidateRolePermissionSync(model.get('roleName'), options?.transaction);
161
178
  });
162
179
 
163
180
  this.app.db.on('aiApiUsageGroups.afterSave', (model, options) => {
@@ -175,6 +192,7 @@ export class PluginAiApiServer extends Plugin {
175
192
  'aiApiRolePermissions:*',
176
193
  'aiApiModelPrices:*',
177
194
  'aiApiModelMetadata:*',
195
+ 'aiApiVirtualModels:*',
178
196
  'aiApiUsageGroups:*',
179
197
  'aiApiGroupMembers:*',
180
198
  'aiApiGroupQuotaBuckets:list',
@@ -189,6 +207,24 @@ export class PluginAiApiServer extends Plugin {
189
207
  // .unref() prevents this timer from keeping the process alive on shutdown.
190
208
  this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
191
209
  this.gcInterval.unref();
210
+
211
+ // Expired stored responses are removed daily; run once at startup as well.
212
+ this.cleanupResponseRecords();
213
+ this.responseCleanupInterval = setInterval(() => this.cleanupResponseRecords(), 24 * 60 * 60 * 1000);
214
+ this.responseCleanupInterval.unref();
215
+ }
216
+
217
+ /**
218
+ * Drop a role's cached permission record on every node.
219
+ *
220
+ * Mirrors invalidateGroupAccess: the local call is not redundant because
221
+ * syncMessageManager hardcodes skipSelf, so the publishing node never receives
222
+ * its own message. Passing the transaction defers the broadcast until the
223
+ * write commits, so other nodes cannot re-read the old row and re-cache it.
224
+ */
225
+ private invalidateRolePermissionSync(roleName: unknown, transaction?: Transactionable['transaction']) {
226
+ invalidateRolePermissionCache(roleName as string | undefined);
227
+ this.sendSyncMessage({ type: 'invalidateRolePermission', roleName }, { transaction });
192
228
  }
193
229
 
194
230
  /**
@@ -206,9 +242,11 @@ export class PluginAiApiServer extends Plugin {
206
242
  /**
207
243
  * Received only on the *other* nodes (skipSelf), so this must not re-broadcast.
208
244
  */
209
- async handleSyncMessage(message: { type?: string; groupId?: unknown }) {
245
+ async handleSyncMessage(message: { type?: string; groupId?: unknown; roleName?: unknown }) {
210
246
  if (message?.type === 'invalidateGroupAccess') {
211
247
  invalidateGroupAccessCache(message.groupId as string | number | bigint);
248
+ } else if (message?.type === 'invalidateRolePermission') {
249
+ invalidateRolePermissionCache(message.roleName as string | undefined);
212
250
  }
213
251
  }
214
252
 
@@ -227,6 +265,19 @@ export class PluginAiApiServer extends Plugin {
227
265
  });
228
266
  }
229
267
 
268
+ // Root and admin get explicit AI API permission rows on fresh installs. There is no
269
+ // built-in role bypass anymore, so without these rows even root/admin would be denied
270
+ // until an admin grants access in Settings → Users & Permissions.
271
+ for (const roleName of ['root', 'admin']) {
272
+ const perm = await this.db.getRepository('aiApiRolePermissions').findOne({
273
+ filter: { roleName },
274
+ });
275
+ if (!perm) {
276
+ await this.db.getRepository('aiApiRolePermissions').create({
277
+ values: { roleName, enabled: true, allowAllEmployees: true, allowedEmployees: [] },
278
+ });
279
+ }
280
+ }
230
281
  // Create default usage group on first install
231
282
  const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
232
283
  filter: { isDefault: true },
@@ -256,6 +307,15 @@ export class PluginAiApiServer extends Plugin {
256
307
  }
257
308
  }
258
309
 
310
+ private async cleanupResponseRecords(): Promise<void> {
311
+ try {
312
+ const deleted = await cleanupExpiredResponseRecords({ db: this.db });
313
+ if (deleted > 0) this.app.logger.info(`[ai-api] Cleaned up ${deleted} expired response records`);
314
+ } catch (error) {
315
+ this.app.logger.warn('[ai-api] Failed to clean up expired response records:', error);
316
+ }
317
+ }
318
+
259
319
  async afterEnable() {}
260
320
 
261
321
  async afterDisable() {}
@@ -266,6 +326,10 @@ export class PluginAiApiServer extends Plugin {
266
326
  clearInterval(this.gcInterval);
267
327
  this.gcInterval = null;
268
328
  }
329
+ if (this.responseCleanupInterval) {
330
+ clearInterval(this.responseCleanupInterval);
331
+ this.responseCleanupInterval = null;
332
+ }
269
333
  this.rateLimiter.clear();
270
334
  }
271
335
  }
@@ -59,7 +59,8 @@ const aiApiConfigResource: ResourceOptions = {
59
59
  },
60
60
 
61
61
  async save(ctx, next) {
62
- const values = ctx.action.params.values || (ctx.request.body as any) || {};
62
+ const values: Record<string, unknown> =
63
+ ctx.action.params.values || (ctx.request.body as Record<string, unknown>) || {};
63
64
  const repo = ctx.db.getRepository('aiApiConfig');
64
65
  let config = await repo.findOne();
65
66
 
@@ -104,6 +104,9 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
104
104
  }
105
105
 
106
106
  // ─── Resolve model ─────────────────────────────────────────────────────────
107
+ // Agent mode runs the AI Employee pipeline, so virtual aliases ("auto") are intentionally
108
+ // not resolved here: capability-bucket routing is a direct-LLM concept. The model field
109
+ // selects the concrete model the employee uses.
107
110
  const resolved = await resolveModelString(ctx, body.model);
108
111
  if (!resolved) {
109
112
  ctx.status = 404;
@@ -20,6 +20,7 @@ import {
20
20
  OpenAIToolCallChunk,
21
21
  } from '../utils/openai-format';
22
22
  import { resolveModelString } from '../utils/resolve-service';
23
+ import { resolveVirtualModel, respondVirtualModelUnavailable } from '../utils/virtual-models';
23
24
  import {
24
25
  createRequestAbortController,
25
26
  isClientDisconnected,
@@ -96,8 +97,17 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
96
97
 
97
98
  const stream = isStreamingRequested(body.stream);
98
99
 
99
- // ─── Resolve model string against DB ───
100
- const resolved = await resolveModelString(ctx, body.model);
100
+ // ─── Resolve model string against DB (virtual alias first, then concrete) ───
101
+ const virtual = await resolveVirtualModel(ctx, body.model, body, 'chat');
102
+ if (virtual?.status === 'unavailable') {
103
+ respondVirtualModelUnavailable(ctx, virtual);
104
+ return;
105
+ }
106
+ if (virtual?.status === 'resolved') {
107
+ ctx.state.aiApiVirtualModel = virtual.virtualModel;
108
+ ctx.state.aiApiRoutingReason = virtual.reason;
109
+ }
110
+ const resolved = virtual?.resolved ?? (await resolveModelString(ctx, body.model));
101
111
  if (!resolved) {
102
112
  ctx.status = 404;
103
113
  ctx.body = toOpenAIError(
@@ -236,7 +246,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
236
246
  chatModel,
237
247
  langchainMessages,
238
248
  completionId,
239
- body.model,
249
+ `${service.name}/${modelId}`,
240
250
  providerRequestParameters,
241
251
  );
242
252
  } else {
@@ -246,7 +256,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
246
256
  chatModel,
247
257
  langchainMessages,
248
258
  completionId,
249
- body.model,
259
+ `${service.name}/${modelId}`,
250
260
  providerRequestParameters,
251
261
  );
252
262
  }
@@ -722,7 +732,7 @@ function describeImageUrlProblem(imageUrl: unknown): string | undefined {
722
732
  * A LangChain message content value: plain text, or an array of content blocks
723
733
  * (`{type:'text'}`, `{type:'image_url'}`, ...) for multimodal requests.
724
734
  */
725
- type MessageContent = string | Record<string, unknown>[];
735
+ export type MessageContent = string | Record<string, unknown>[];
726
736
 
727
737
  /**
728
738
  * Normalize an OpenAI `message.content` into something LangChain accepts.
@@ -763,7 +773,7 @@ export function normalizeMessageContent(content: unknown): MessageContent {
763
773
  * block (e.g. a `file_url` becomes a `file` block, which may then be converted
764
774
  * to images by the PDF processor).
765
775
  */
766
- async function processMessageContentFileBlocks(
776
+ export async function processMessageContentFileBlocks(
767
777
  content: unknown,
768
778
  ctx: Context,
769
779
  plugin: PluginAiApiServer,
@@ -807,7 +817,21 @@ async function processFileBlockChain(
807
817
  return next;
808
818
  }
809
819
 
810
- const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'prompt', 'tools', 'tool_choice', 'stream', 'n']);
820
+ const GATEWAY_MANAGED_PARAMETERS = new Set([
821
+ 'model',
822
+ 'messages',
823
+ 'prompt',
824
+ 'tools',
825
+ 'tool_choice',
826
+ 'stream',
827
+ 'n',
828
+ // Responses API specific fields that should not be passed to providers
829
+ 'input',
830
+ 'previous_response_id',
831
+ 'store',
832
+ 'truncation',
833
+ 'metadata',
834
+ ]);
811
835
 
812
836
  export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
813
837
  return Object.fromEntries(
@@ -836,7 +860,7 @@ export function applyProviderRequestParameters(chatModel: unknown, parameters: R
836
860
  model.modelKwargs = { ...modelKwargs, ...parameters };
837
861
  }
838
862
 
839
- function bindRequestTools(
863
+ export function bindRequestTools(
840
864
  chatModel: any,
841
865
  tools: unknown,
842
866
  toolChoice: unknown,
@@ -865,7 +889,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
865
889
  return typeof value === 'object' && value !== null && !Array.isArray(value);
866
890
  }
867
891
 
868
- function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
892
+ export function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
869
893
  if (!Array.isArray(value) || value.length === 0) return undefined;
870
894
  return value.map((call: any) => ({
871
895
  id: String(call.id || ''),
@@ -877,7 +901,7 @@ function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
877
901
  }));
878
902
  }
879
903
 
880
- function normalizeToolCallChunks(value: unknown): OpenAIToolCallChunk[] {
904
+ export function normalizeToolCallChunks(value: unknown): OpenAIToolCallChunk[] {
881
905
  if (!Array.isArray(value)) return [];
882
906
  return value.map((call: any, fallbackIndex) => ({
883
907
  index: typeof call.index === 'number' ? call.index : fallbackIndex,
@@ -16,6 +16,7 @@ import {
16
16
  toOpenAIUsageChunk,
17
17
  } from '../utils/openai-format';
18
18
  import { resolveModelString } from '../utils/resolve-service';
19
+ import { resolveVirtualModel, respondVirtualModelUnavailable } from '../utils/virtual-models';
19
20
  import { enforceModelAccess } from '../utils/user-permissions';
20
21
  import {
21
22
  createRequestAbortController,
@@ -70,7 +71,16 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
70
71
  const stream = isStreamingRequested(body.stream);
71
72
 
72
73
  // ─── Resolve model string against DB ───
73
- const resolved = await resolveModelString(ctx, body.model);
74
+ const virtual = await resolveVirtualModel(ctx, body.model, body, 'chat');
75
+ if (virtual?.status === 'unavailable') {
76
+ respondVirtualModelUnavailable(ctx, virtual);
77
+ return;
78
+ }
79
+ if (virtual?.status === 'resolved') {
80
+ ctx.state.aiApiVirtualModel = virtual.virtualModel;
81
+ ctx.state.aiApiRoutingReason = virtual.reason;
82
+ }
83
+ const resolved = virtual?.resolved ?? (await resolveModelString(ctx, body.model));
74
84
  if (!resolved) {
75
85
  ctx.status = 404;
76
86
  ctx.body = toOpenAIError(
@@ -124,7 +134,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
124
134
 
125
135
  if (body.temperature !== undefined) modelOptions.temperature = body.temperature;
126
136
  if (body.top_p !== undefined) modelOptions.topP = body.top_p;
127
- if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
137
+ if (body.max_completion_tokens !== undefined) modelOptions.maxTokens = body.max_completion_tokens;
138
+ else if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
128
139
  if (body.stop !== undefined) modelOptions.stop = body.stop;
129
140
 
130
141
  // ─── Convert prompt to message tuple ───
@@ -142,6 +153,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
142
153
  serviceName: service.name,
143
154
  modelId,
144
155
  messages,
156
+ maxCompletionTokens: body.max_completion_tokens,
145
157
  maxTokens: body.max_tokens,
146
158
  });
147
159
  await prepareLlmBilling(ctx, resolved);
@@ -169,7 +181,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
169
181
  chatModel,
170
182
  langchainMessages,
171
183
  completionId,
172
- body.model,
184
+ `${service.name}/${modelId}`,
173
185
  body.stream_options,
174
186
  providerRequestParameters,
175
187
  );
@@ -179,7 +191,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
179
191
  chatModel,
180
192
  langchainMessages,
181
193
  completionId,
182
- body.model,
194
+ `${service.name}/${modelId}`,
183
195
  providerRequestParameters,
184
196
  );
185
197
  }
@@ -10,11 +10,12 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
12
12
  import { resolveModelString } from '../utils/resolve-service';
13
+ import { resolveVirtualModel, respondVirtualModelUnavailable } from '../utils/virtual-models';
13
14
  import { enforceModelAccess } from '../utils/user-permissions';
14
15
  import { getAiApiConfig } from '../utils/request-cache';
15
16
  import { setAiApiUsageResult } from '../usage';
16
17
  import type PluginAiApiServer from '../plugin';
17
- import { markLlmProviderAttempted, prepareLlmBilling, finalizeLlmBilling } from '../billing';
18
+ import { markLlmProviderAttempted, prepareLlmBilling, AiApiQuotaError } from '../billing';
18
19
 
19
20
  /**
20
21
  * POST /api/ai-llm/v1/embeddings
@@ -90,7 +91,16 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
90
91
  }
91
92
 
92
93
  // ─── Resolve model ────────────────────────────────────────────────────────
93
- const resolved = await resolveModelString(ctx, body.model);
94
+ const virtual = await resolveVirtualModel(ctx, body.model, body, 'embedding');
95
+ if (virtual?.status === 'unavailable') {
96
+ respondVirtualModelUnavailable(ctx, virtual);
97
+ return;
98
+ }
99
+ if (virtual?.status === 'resolved') {
100
+ ctx.state.aiApiVirtualModel = virtual.virtualModel;
101
+ ctx.state.aiApiRoutingReason = virtual.reason;
102
+ }
103
+ const resolved = virtual?.resolved ?? (await resolveModelString(ctx, body.model));
94
104
  if (!resolved) {
95
105
  ctx.status = 404;
96
106
  ctx.body = toOpenAIError(
@@ -104,9 +114,6 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
104
114
 
105
115
  const { service, modelId } = resolved;
106
116
 
107
- // ─── Prepare billing/quota ────────────────────────────────────────────────
108
- await prepareLlmBilling(ctx, resolved);
109
-
110
117
  if (service.enabled === false) {
111
118
  ctx.status = 404;
112
119
  ctx.body = toOpenAIError(
@@ -160,7 +167,12 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
160
167
  return;
161
168
  }
162
169
 
170
+ // ─── Prepare billing/quota after access checks ────────────────────────────
171
+ // Running this after service.enabled and enforceModelAccess avoids a needless
172
+ // reserve/release round-trip on every rejected request.
173
+
163
174
  try {
175
+ await prepareLlmBilling(ctx, resolved);
164
176
  // ─── Instantiate and call the embedding provider ──────────────────────
165
177
  const EmbeddingClass = providerMeta.embedding;
166
178
  const embeddingProvider = new EmbeddingClass({
@@ -183,12 +195,11 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
183
195
  total_tokens: estimatedInputTokens,
184
196
  };
185
197
  setAiApiUsageResult(ctx, usage);
186
- await finalizeLlmBilling(ctx, usage, true);
187
198
 
188
199
  ctx.status = 200;
189
200
  ctx.set('Content-Type', 'application/json');
190
201
  ctx.body = toOpenAIEmbeddingsResponse({
191
- model: body.model,
202
+ model: `${service.name}/${modelId}`,
192
203
  embeddings: vectors,
193
204
  // LangChain's EmbeddingsInterface does not expose token counts.
194
205
  promptTokens: null,
@@ -196,8 +207,19 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
196
207
  } catch (err) {
197
208
  ctx.log.error('AI API embeddings error:', err);
198
209
  if (!ctx.res.headersSent) {
199
- ctx.status = 500;
200
- ctx.body = toOpenAIError(500, err.message || 'Failed to generate embeddings', 'server_error');
210
+ const isQuotaError = err instanceof AiApiQuotaError;
211
+ ctx.status = isQuotaError ? 429 : 500;
212
+ if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
213
+ ctx.body = toOpenAIError(
214
+ ctx.status,
215
+ getErrorMessage(err, 'Failed to generate embeddings'),
216
+ isQuotaError ? 'quota_error' : 'server_error',
217
+ isQuotaError ? err.code : undefined,
218
+ );
201
219
  }
202
220
  }
203
- }
221
+ }
222
+
223
+ function getErrorMessage(error: unknown, fallback: string) {
224
+ return error instanceof Error && error.message ? error.message : fallback;
225
+ }
@@ -11,6 +11,7 @@ import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError } from '../utils/openai-format';
12
12
  import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
13
13
  import { getAiApiConfig } from '../utils/request-cache';
14
+ import { listAccessibleVirtualModels, type VirtualModel } from '../utils/virtual-models';
14
15
  import type PluginAiApiServer from '../plugin';
15
16
 
16
17
  /**
@@ -75,6 +76,17 @@ export async function handleListModels(ctx: Context, plugin: PluginAiApiServer)
75
76
  }
76
77
  }
77
78
 
79
+ // Expose enabled virtual aliases (e.g. "auto") so clients can discover and use them.
80
+ // They carry virtual:true and a description, but no capability overrides of their own.
81
+ try {
82
+ const virtualModels = await listAccessibleVirtualModels(ctx, scope, config?.enabledLlmServices);
83
+ for (const virtualModel of virtualModels) {
84
+ models.push(buildVirtualModelObject(virtualModel, now));
85
+ }
86
+ } catch {
87
+ // Virtual models table may not exist yet during a rolling upgrade — skip silently.
88
+ }
89
+
78
90
  ctx.status = 200;
79
91
  ctx.body = {
80
92
  object: 'list',
@@ -137,6 +149,16 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
137
149
  if (found) break;
138
150
  }
139
151
 
152
+ if (!found) {
153
+ try {
154
+ const virtualModels = await listAccessibleVirtualModels(ctx, scope, config?.enabledLlmServices);
155
+ const virtualModel = virtualModels.find((candidate) => candidate.name === modelId);
156
+ if (virtualModel) found = buildVirtualModelObject(virtualModel, now);
157
+ } catch {
158
+ // Virtual models table may not exist yet during a rolling upgrade.
159
+ }
160
+ }
161
+
140
162
  if (!found) {
141
163
  ctx.status = 404;
142
164
  ctx.body = toOpenAIError(404, `Model '${modelId}' not found`, 'invalid_request_error', 'model_not_found');
@@ -246,6 +268,18 @@ export function buildModelObject(
246
268
  return model;
247
269
  }
248
270
 
271
+ export function buildVirtualModelObject(virtualModel: VirtualModel, created: number): Record<string, unknown> {
272
+ return {
273
+ id: virtualModel.name,
274
+ object: 'model',
275
+ created,
276
+ owned_by: 'ai-api-gateway',
277
+ virtual: true,
278
+ mode: virtualModel.mode,
279
+ description: `Virtual ${virtualModel.mode} alias routed to a concrete model based on the request shape.`,
280
+ };
281
+ }
282
+
249
283
  function toPositiveInt(value: unknown): number | null {
250
284
  const n = Number(value);
251
285
  return Number.isSafeInteger(n) && n > 0 ? n : null;