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.
- package/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +175 -139
- package/dist/locale/vi-VN.json +40 -2
- package/dist/locale/zh-CN.json +40 -2
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +25 -6
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +25 -4
- package/dist/server/utils/direct-llm-context.js +1 -1
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +44 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/locale/en-US.json +175 -139
- package/src/locale/vi-VN.json +40 -2
- package/src/locale/zh-CN.json +40 -2
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +14 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +32 -10
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +29 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +2 -1
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +49 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
package/dist/swagger.js
CHANGED
|
@@ -197,6 +197,143 @@ var swagger_default = {
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
},
|
|
200
|
+
"/ai-llm/v1/responses": {
|
|
201
|
+
post: {
|
|
202
|
+
tags: ["ai-llm"],
|
|
203
|
+
summary: "Create a model response",
|
|
204
|
+
description: "OpenAI Responses API endpoint. Supports text/image/file inputs, function calling, streaming, reasoning output when exposed by the provider, and owner-scoped previous_response_id chains. Built-in web_search and file_search tools are not supported.",
|
|
205
|
+
security: [{ BearerAuth: [] }],
|
|
206
|
+
requestBody: {
|
|
207
|
+
required: true,
|
|
208
|
+
content: {
|
|
209
|
+
"application/json": {
|
|
210
|
+
schema: {
|
|
211
|
+
type: "object",
|
|
212
|
+
properties: {
|
|
213
|
+
model: { type: "string", example: "openai/gpt-4o" },
|
|
214
|
+
input: {
|
|
215
|
+
oneOf: [{ type: "string" }, { type: "array", items: { type: "object" } }]
|
|
216
|
+
},
|
|
217
|
+
instructions: { type: "string" },
|
|
218
|
+
max_output_tokens: { type: "integer" },
|
|
219
|
+
temperature: { type: "number", minimum: 0, maximum: 2 },
|
|
220
|
+
top_p: { type: "number", minimum: 0, maximum: 1 },
|
|
221
|
+
tools: { type: "array", description: "Function tools only", items: { type: "object" } },
|
|
222
|
+
tool_choice: { oneOf: [{ type: "string" }, { type: "object" }] },
|
|
223
|
+
metadata: { type: "object" },
|
|
224
|
+
previous_response_id: { type: "string" },
|
|
225
|
+
store: { type: "boolean", default: true },
|
|
226
|
+
stream: { type: "boolean", default: false },
|
|
227
|
+
truncation: { type: "string", enum: ["auto", "disabled"], default: "disabled" },
|
|
228
|
+
service_tier: { type: "string", enum: ["auto", "default", "flex", "scale", "priority"] },
|
|
229
|
+
prompt_cache_key: { type: "string" },
|
|
230
|
+
prompt_cache_retention: { type: "string", enum: ["in_memory", "24h"], nullable: true },
|
|
231
|
+
reasoning: { type: "object" },
|
|
232
|
+
safety_identifier: { type: "string", maxLength: 64 },
|
|
233
|
+
parallel_tool_calls: { type: "boolean", default: true },
|
|
234
|
+
text: { type: "object" }
|
|
235
|
+
},
|
|
236
|
+
required: ["model", "input"]
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
responses: {
|
|
242
|
+
200: {
|
|
243
|
+
description: "OpenAI Response object, or Responses API SSE events when stream=true",
|
|
244
|
+
content: {
|
|
245
|
+
"application/json": {
|
|
246
|
+
schema: {
|
|
247
|
+
type: "object",
|
|
248
|
+
properties: {
|
|
249
|
+
id: { type: "string", example: "resp_abc123" },
|
|
250
|
+
object: { type: "string", const: "response" },
|
|
251
|
+
created_at: { type: "integer" },
|
|
252
|
+
completed_at: { type: "integer", nullable: true },
|
|
253
|
+
model: { type: "string" },
|
|
254
|
+
status: {
|
|
255
|
+
type: "string",
|
|
256
|
+
enum: ["completed", "failed", "in_progress", "cancelled", "queued", "incomplete"]
|
|
257
|
+
},
|
|
258
|
+
output_text: { type: "string" },
|
|
259
|
+
output: { type: "array", items: { type: "object" } },
|
|
260
|
+
usage: {
|
|
261
|
+
type: "object",
|
|
262
|
+
properties: {
|
|
263
|
+
input_tokens: { type: "integer" },
|
|
264
|
+
input_tokens_details: {
|
|
265
|
+
type: "object",
|
|
266
|
+
properties: { cached_tokens: { type: "integer" } }
|
|
267
|
+
},
|
|
268
|
+
output_tokens: { type: "integer" },
|
|
269
|
+
output_tokens_details: {
|
|
270
|
+
type: "object",
|
|
271
|
+
properties: { reasoning_tokens: { type: "integer" } }
|
|
272
|
+
},
|
|
273
|
+
total_tokens: { type: "integer" }
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
metadata: { type: "object", nullable: true },
|
|
277
|
+
previous_response_id: { type: "string", nullable: true },
|
|
278
|
+
truncation: { type: "string", enum: ["auto", "disabled"] },
|
|
279
|
+
error: { type: "object", nullable: true }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"text/event-stream": { schema: { type: "string" } }
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
400: { description: "Invalid request or unsupported tool" },
|
|
287
|
+
404: { description: "Model or previous response not found" },
|
|
288
|
+
429: { description: "Rate limit or quota exceeded" }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
"/ai-llm/v1/responses/{id}": {
|
|
293
|
+
get: {
|
|
294
|
+
tags: ["ai-llm"],
|
|
295
|
+
summary: "Retrieve a stored model response",
|
|
296
|
+
description: "Returns a non-expired response created by the authenticated user. Responses created with store=false cannot be retrieved.",
|
|
297
|
+
security: [{ BearerAuth: [] }],
|
|
298
|
+
parameters: [
|
|
299
|
+
{
|
|
300
|
+
name: "id",
|
|
301
|
+
in: "path",
|
|
302
|
+
required: true,
|
|
303
|
+
schema: { type: "string", example: "resp_abc123" }
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: "stream",
|
|
307
|
+
in: "query",
|
|
308
|
+
schema: { type: "boolean", default: false },
|
|
309
|
+
description: "Only false is currently supported; stream retrieval is rejected."
|
|
310
|
+
}
|
|
311
|
+
],
|
|
312
|
+
responses: {
|
|
313
|
+
200: { description: "Stored OpenAI Response object" },
|
|
314
|
+
400: { description: "Unsupported retrieve expansion or streaming parameter" },
|
|
315
|
+
404: { description: "Response not found, expired, or owned by another user" }
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
delete: {
|
|
319
|
+
tags: ["ai-llm"],
|
|
320
|
+
summary: "Delete a stored model response",
|
|
321
|
+
description: "Deletes a stored response owned by the authenticated user.",
|
|
322
|
+
security: [{ BearerAuth: [] }],
|
|
323
|
+
parameters: [
|
|
324
|
+
{
|
|
325
|
+
name: "id",
|
|
326
|
+
in: "path",
|
|
327
|
+
required: true,
|
|
328
|
+
schema: { type: "string", example: "resp_abc123" }
|
|
329
|
+
}
|
|
330
|
+
],
|
|
331
|
+
responses: {
|
|
332
|
+
204: { description: "Response deleted" },
|
|
333
|
+
404: { description: "Response not found, expired, or owned by another user" }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
200
337
|
"/ai-llm/v1/embeddings": {
|
|
201
338
|
post: {
|
|
202
339
|
tags: ["ai-llm"],
|
package/package.json
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "plugin-ai-api",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"main": "dist/server/index.js",
|
|
5
|
-
"dependencies": {},
|
|
6
|
-
"peerDependencies": {
|
|
7
|
-
"@nocobase/client": "2.x",
|
|
8
|
-
"@nocobase/server": "2.x",
|
|
9
|
-
"@nocobase/database": "2.x",
|
|
10
|
-
"@nocobase/plugin-acl": "2.x",
|
|
11
|
-
"@nocobase/plugin-ai": "2.x",
|
|
12
|
-
"@nocobase/plugin-api-keys": "2.x",
|
|
13
|
-
"@nocobase/client-v2": "2.x",
|
|
14
|
-
"@nocobase/flow-engine": "2.x"
|
|
15
|
-
},
|
|
16
|
-
"nocobase": {
|
|
17
|
-
"supportedVersions": [
|
|
18
|
-
"2.x"
|
|
19
|
-
],
|
|
20
|
-
"editionLevel": 0
|
|
21
|
-
},
|
|
22
|
-
"files": [
|
|
23
|
-
"dist",
|
|
24
|
-
"src",
|
|
25
|
-
"client.js",
|
|
26
|
-
"client-v2.js",
|
|
27
|
-
"server.js",
|
|
28
|
-
"client.d.ts",
|
|
29
|
-
"client-v2.d.ts",
|
|
30
|
-
"server.d.ts"
|
|
31
|
-
],
|
|
32
|
-
"license": "Apache-2.0"
|
|
1
|
+
{
|
|
2
|
+
"name": "plugin-ai-api",
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"main": "dist/server/index.js",
|
|
5
|
+
"dependencies": {},
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"@nocobase/client": "2.x",
|
|
8
|
+
"@nocobase/server": "2.x",
|
|
9
|
+
"@nocobase/database": "2.x",
|
|
10
|
+
"@nocobase/plugin-acl": "2.x",
|
|
11
|
+
"@nocobase/plugin-ai": "2.x",
|
|
12
|
+
"@nocobase/plugin-api-keys": "2.x",
|
|
13
|
+
"@nocobase/client-v2": "2.x",
|
|
14
|
+
"@nocobase/flow-engine": "2.x"
|
|
15
|
+
},
|
|
16
|
+
"nocobase": {
|
|
17
|
+
"supportedVersions": [
|
|
18
|
+
"2.x"
|
|
19
|
+
],
|
|
20
|
+
"editionLevel": 0
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"client.js",
|
|
26
|
+
"client-v2.js",
|
|
27
|
+
"server.js",
|
|
28
|
+
"client.d.ts",
|
|
29
|
+
"client-v2.d.ts",
|
|
30
|
+
"server.d.ts"
|
|
31
|
+
],
|
|
32
|
+
"license": "Apache-2.0"
|
|
33
|
+
,
|
|
34
|
+
"keywords": "AI"
|
|
33
35
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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 { readFileSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
|
|
14
|
+
const LOCALE_DIR = join(__dirname, '..', 'locale');
|
|
15
|
+
const FILES = ['en-US.json', 'zh-CN.json', 'vi-VN.json'] as const;
|
|
16
|
+
|
|
17
|
+
function loadLocale(file: string): Record<string, string> {
|
|
18
|
+
return JSON.parse(readFileSync(join(LOCALE_DIR, file), 'utf8')) as Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('plugin-ai-api locale files', () => {
|
|
22
|
+
it('all locale files are valid JSON with no empty values', () => {
|
|
23
|
+
for (const file of FILES) {
|
|
24
|
+
const locale = loadLocale(file);
|
|
25
|
+
expect(Object.keys(locale).length, `${file} should not be empty`).toBeGreaterThan(0);
|
|
26
|
+
for (const [key, value] of Object.entries(locale)) {
|
|
27
|
+
expect(typeof value, `${file}[${JSON.stringify(key)}] must be a string`).toBe('string');
|
|
28
|
+
expect(value.trim().length, `${file}[${JSON.stringify(key)}] must not be blank`).toBeGreaterThan(0);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('all locales expose the identical set of keys', () => {
|
|
34
|
+
const [base, ...rest] = FILES;
|
|
35
|
+
const baseKeys = Object.keys(loadLocale(base)).sort();
|
|
36
|
+
for (const file of rest) {
|
|
37
|
+
const keys = Object.keys(loadLocale(file)).sort();
|
|
38
|
+
const missing = baseKeys.filter((key) => !keys.includes(key));
|
|
39
|
+
const extra = keys.filter((key) => !baseKeys.includes(key));
|
|
40
|
+
expect({ file, missing, extra }).toEqual({ file, missing: [], extra: [] });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/client/plugin.tsx
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Plugin, lazy } from '@nocobase/client';
|
|
11
|
-
import PluginACLClient from '@nocobase/plugin-acl
|
|
11
|
+
import PluginACLClient from '@nocobase/plugin-acl';
|
|
12
12
|
import { AI_API_ACL_SNIPPET } from '../constants';
|
|
13
13
|
import React from 'react';
|
|
14
14
|
|
|
15
15
|
const AiApiConfigPage = React.lazy(() => import('../client-v2/pages/GeneralPage'));
|
|
16
16
|
const AiApiModelPricingPage = React.lazy(() => import('../client-v2/pages/ModelPricingPage'));
|
|
17
17
|
const AiApiModelMetadataPage = React.lazy(() => import('../client-v2/pages/ModelMetadataPage'));
|
|
18
|
+
const AiApiModelRoutingPage = React.lazy(() => import('../client-v2/pages/ModelRoutingPage'));
|
|
18
19
|
const AiApiUsageGroupsPage = React.lazy(() => import('../client-v2/pages/UsageGroupsPage'));
|
|
19
20
|
const AiApiUsagePage = React.lazy(() => import('../client-v2/pages/UsagePage'));
|
|
20
21
|
const { AiApiRolePermissions } = lazy(() => import('./components/AiApiRolePermissions'), 'AiApiRolePermissions');
|
|
@@ -48,6 +49,13 @@ export class PluginAiApiClient extends Plugin {
|
|
|
48
49
|
sort: 3,
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
this.app.pluginSettingsManager.add('ai-api.model-routing', {
|
|
53
|
+
title: this.t('Model routing'),
|
|
54
|
+
Component: AiApiModelRoutingPage,
|
|
55
|
+
aclSnippet: AI_API_ACL_SNIPPET,
|
|
56
|
+
sort: 4,
|
|
57
|
+
});
|
|
58
|
+
|
|
51
59
|
this.app.pluginSettingsManager.add('ai-api.usage-groups', {
|
|
52
60
|
title: this.t('Usage groups'),
|
|
53
61
|
Component: AiApiUsageGroupsPage,
|
|
@@ -23,6 +23,10 @@ interface ModelMetadata {
|
|
|
23
23
|
id: string | number;
|
|
24
24
|
llmService: string;
|
|
25
25
|
model: string;
|
|
26
|
+
supportsVision?: boolean;
|
|
27
|
+
supportsToolCalling?: boolean;
|
|
28
|
+
reasoningTier?: string;
|
|
29
|
+
sortOrder?: number;
|
|
26
30
|
contextWindow?: number | null;
|
|
27
31
|
maxCompletionTokens?: number | null;
|
|
28
32
|
ownedByOverride?: string | null;
|
|
@@ -138,6 +142,10 @@ export default function ModelMetadataPage() {
|
|
|
138
142
|
displayName: values.displayName?.trim() || null,
|
|
139
143
|
description: values.description?.trim() || null,
|
|
140
144
|
systemPrompt: values.systemPrompt?.trim() || null,
|
|
145
|
+
supportsVision: !!values.supportsVision,
|
|
146
|
+
supportsToolCalling: values.supportsToolCalling !== false,
|
|
147
|
+
reasoningTier: values.reasoningTier || 'general',
|
|
148
|
+
sortOrder: values.sortOrder ?? 0,
|
|
141
149
|
};
|
|
142
150
|
setSaving(true);
|
|
143
151
|
try {
|
|
@@ -289,6 +297,42 @@ export default function ModelMetadataPage() {
|
|
|
289
297
|
>
|
|
290
298
|
<Input.TextArea rows={4} placeholder={t('Leave empty to not override')} />
|
|
291
299
|
</Form.Item>
|
|
300
|
+
<Form.Item
|
|
301
|
+
name="supportsVision"
|
|
302
|
+
label={t('Supports vision')}
|
|
303
|
+
valuePropName="checked"
|
|
304
|
+
tooltip={t('Used by virtual-model routing for image/file requests.')}
|
|
305
|
+
>
|
|
306
|
+
<Switch />
|
|
307
|
+
</Form.Item>
|
|
308
|
+
<Form.Item
|
|
309
|
+
name="supportsToolCalling"
|
|
310
|
+
label={t('Supports tool calling')}
|
|
311
|
+
valuePropName="checked"
|
|
312
|
+
tooltip={t('Used by virtual-model routing for requests with tools/tool_choice.')}
|
|
313
|
+
>
|
|
314
|
+
<Switch defaultChecked />
|
|
315
|
+
</Form.Item>
|
|
316
|
+
<Form.Item
|
|
317
|
+
name="reasoningTier"
|
|
318
|
+
label={t('Reasoning tier')}
|
|
319
|
+
tooltip={t('cheap | general | reasoning. Used by virtual-model routing buckets.')}
|
|
320
|
+
>
|
|
321
|
+
<Select
|
|
322
|
+
options={[
|
|
323
|
+
{ value: 'cheap', label: t('Cheap') },
|
|
324
|
+
{ value: 'general', label: t('General') },
|
|
325
|
+
{ value: 'reasoning', label: t('Reasoning') },
|
|
326
|
+
]}
|
|
327
|
+
/>
|
|
328
|
+
</Form.Item>
|
|
329
|
+
<Form.Item
|
|
330
|
+
name="sortOrder"
|
|
331
|
+
label={t('Routing priority')}
|
|
332
|
+
tooltip={t('Ascending — lower is preferred when a bucket is derived from metadata.')}
|
|
333
|
+
>
|
|
334
|
+
<InputNumber precision={0} style={{ width: '100%' }} placeholder="0" />
|
|
335
|
+
</Form.Item>
|
|
292
336
|
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
293
337
|
<Switch />
|
|
294
338
|
</Form.Item>
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { Button, Card, Form, Input, Select, Space, Switch, Typography, message } from 'antd';
|
|
3
|
+
import { useFlowContext } from '@nocobase/flow-engine';
|
|
4
|
+
import { useT } from '../locale';
|
|
5
|
+
import { errorMessage, unwrapData } from './api';
|
|
6
|
+
|
|
7
|
+
const { Text } = Typography;
|
|
8
|
+
|
|
9
|
+
interface VirtualModel {
|
|
10
|
+
id?: string | number;
|
|
11
|
+
name: string;
|
|
12
|
+
mode: string;
|
|
13
|
+
fallbackModel: string;
|
|
14
|
+
visionModels: string[];
|
|
15
|
+
toolModels: string[];
|
|
16
|
+
reasoningModels: string[];
|
|
17
|
+
cheapModels: string[];
|
|
18
|
+
generalModels: string[];
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface EnabledLlmService {
|
|
23
|
+
llmService: string;
|
|
24
|
+
llmServiceTitle?: string;
|
|
25
|
+
enabledModels?: Array<{ label?: string; value: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ModelOption {
|
|
29
|
+
value: string; // "serviceName/modelId"
|
|
30
|
+
label: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_ALIAS = 'auto';
|
|
34
|
+
|
|
35
|
+
export default function ModelRoutingPage() {
|
|
36
|
+
const ctx = useFlowContext();
|
|
37
|
+
const t = useT();
|
|
38
|
+
const [form] = Form.useForm<Partial<VirtualModel>>();
|
|
39
|
+
const [loading, setLoading] = useState(true);
|
|
40
|
+
const [saving, setSaving] = useState(false);
|
|
41
|
+
const [options, setOptions] = useState<ModelOption[]>([]);
|
|
42
|
+
const [recordId, setRecordId] = useState<string | number | undefined>();
|
|
43
|
+
|
|
44
|
+
const load = useCallback(async () => {
|
|
45
|
+
setLoading(true);
|
|
46
|
+
try {
|
|
47
|
+
const [vmRes, servicesRes] = await Promise.all([
|
|
48
|
+
ctx.api.request({
|
|
49
|
+
url: 'aiApiVirtualModels:list',
|
|
50
|
+
method: 'get',
|
|
51
|
+
params: { filter: { name: DEFAULT_ALIAS }, pageSize: 1 },
|
|
52
|
+
}),
|
|
53
|
+
// Use the normalized catalog so recommended/provider/custom modes all surface their
|
|
54
|
+
// actual runtime models. The raw llmServices endpoint can return an empty enabledModels
|
|
55
|
+
// array for recommended-mode services, which would leave this page with no choices.
|
|
56
|
+
ctx.api.request({ url: 'ai:listAllEnabledModels', method: 'get' }),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Build the shared option list: every enabled model of every service, as "service/model".
|
|
60
|
+
const svcList = unwrapData<EnabledLlmService[]>(servicesRes, []);
|
|
61
|
+
const opts: ModelOption[] = [];
|
|
62
|
+
for (const service of svcList) {
|
|
63
|
+
const label = service.llmServiceTitle || service.llmService;
|
|
64
|
+
for (const model of service.enabledModels || []) {
|
|
65
|
+
if (!model?.value) continue;
|
|
66
|
+
opts.push({
|
|
67
|
+
value: `${service.llmService}/${model.value}`,
|
|
68
|
+
label: `${label} / ${model.label || model.value}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const existing = unwrapData<VirtualModel[]>(vmRes, [])[0];
|
|
74
|
+
// Keep stale references visible: values already configured on this alias but no longer
|
|
75
|
+
// present in the enabled catalog are merged back into the options so admins can still
|
|
76
|
+
// see, reorder, and replace them instead of being left with an opaque raw value.
|
|
77
|
+
if (existing) {
|
|
78
|
+
const known = new Set(opts.map((option) => option.value));
|
|
79
|
+
const configured = [
|
|
80
|
+
existing.fallbackModel,
|
|
81
|
+
...(existing.visionModels || []),
|
|
82
|
+
...(existing.toolModels || []),
|
|
83
|
+
...(existing.reasoningModels || []),
|
|
84
|
+
...(existing.cheapModels || []),
|
|
85
|
+
...(existing.generalModels || []),
|
|
86
|
+
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
|
87
|
+
for (const value of configured) {
|
|
88
|
+
if (known.has(value)) continue;
|
|
89
|
+
known.add(value);
|
|
90
|
+
opts.push({ value, label: `${value} (${t('unavailable')})` });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
setOptions(opts);
|
|
94
|
+
if (existing) {
|
|
95
|
+
setRecordId(existing.id);
|
|
96
|
+
form.setFieldsValue({
|
|
97
|
+
name: existing.name,
|
|
98
|
+
mode: existing.mode || 'chat',
|
|
99
|
+
fallbackModel: existing.fallbackModel,
|
|
100
|
+
visionModels: existing.visionModels || [],
|
|
101
|
+
toolModels: existing.toolModels || [],
|
|
102
|
+
reasoningModels: existing.reasoningModels || [],
|
|
103
|
+
cheapModels: existing.cheapModels || [],
|
|
104
|
+
generalModels: existing.generalModels || [],
|
|
105
|
+
enabled: existing.enabled !== false,
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
form.setFieldsValue({
|
|
109
|
+
name: DEFAULT_ALIAS,
|
|
110
|
+
mode: 'chat',
|
|
111
|
+
fallbackModel: undefined,
|
|
112
|
+
enabled: true,
|
|
113
|
+
visionModels: [],
|
|
114
|
+
toolModels: [],
|
|
115
|
+
reasoningModels: [],
|
|
116
|
+
cheapModels: [],
|
|
117
|
+
generalModels: [],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
message.error(errorMessage(error));
|
|
122
|
+
} finally {
|
|
123
|
+
setLoading(false);
|
|
124
|
+
}
|
|
125
|
+
}, [ctx.api, form, t]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
load();
|
|
129
|
+
}, [load]);
|
|
130
|
+
|
|
131
|
+
const save = async () => {
|
|
132
|
+
const values = await form.validateFields();
|
|
133
|
+
setSaving(true);
|
|
134
|
+
try {
|
|
135
|
+
if (recordId) {
|
|
136
|
+
await ctx.api.request({ url: `aiApiVirtualModels:update/${recordId}`, method: 'post', data: values });
|
|
137
|
+
} else {
|
|
138
|
+
const res = await ctx.api.request({ url: 'aiApiVirtualModels:create', method: 'post', data: values });
|
|
139
|
+
const created = unwrapData<VirtualModel | undefined>(res, undefined);
|
|
140
|
+
if (created?.id) setRecordId(created.id);
|
|
141
|
+
}
|
|
142
|
+
message.success(t('Saved successfully'));
|
|
143
|
+
} catch (error) {
|
|
144
|
+
message.error(errorMessage(error));
|
|
145
|
+
} finally {
|
|
146
|
+
setSaving(false);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Keep stored order visible: render selected tags in the order of the field value,
|
|
151
|
+
// and let the dropdown offer the remaining options.
|
|
152
|
+
const orderedSelectProps = {
|
|
153
|
+
options,
|
|
154
|
+
optionFilterProp: 'label' as const,
|
|
155
|
+
showSearch: true,
|
|
156
|
+
loading,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const listField = (name: keyof VirtualModel, label: string, tooltip: string) => (
|
|
160
|
+
<Form.Item name={name} label={label} tooltip={tooltip}>
|
|
161
|
+
<Select
|
|
162
|
+
mode="multiple"
|
|
163
|
+
placeholder={t('Select models — the order shown is the routing priority')}
|
|
164
|
+
{...orderedSelectProps}
|
|
165
|
+
/>
|
|
166
|
+
</Form.Item>
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<Card loading={loading} title={t('Model routing (virtual models)')}>
|
|
171
|
+
<Form form={form} layout="vertical">
|
|
172
|
+
<Form.Item name="name" label={t('Alias')} rules={[{ required: true }]}>
|
|
173
|
+
<Input disabled style={{ maxWidth: 240 }} />
|
|
174
|
+
</Form.Item>
|
|
175
|
+
<Form.Item name="mode" hidden>
|
|
176
|
+
<Input />
|
|
177
|
+
</Form.Item>
|
|
178
|
+
<Form.Item
|
|
179
|
+
name="fallbackModel"
|
|
180
|
+
label={t('Fallback model')}
|
|
181
|
+
rules={[{ required: true, message: t('A fallback model is required') }]}
|
|
182
|
+
tooltip={t(
|
|
183
|
+
'Used when no capability bucket candidate is usable and the fallback is permitted for the caller.',
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
<Select
|
|
187
|
+
placeholder={t('Select a fallback model')}
|
|
188
|
+
options={options}
|
|
189
|
+
optionFilterProp="label"
|
|
190
|
+
showSearch
|
|
191
|
+
style={{ maxWidth: 480 }}
|
|
192
|
+
/>
|
|
193
|
+
</Form.Item>
|
|
194
|
+
|
|
195
|
+
{listField(
|
|
196
|
+
'visionModels',
|
|
197
|
+
t('Vision models (in order)'),
|
|
198
|
+
t('Requests with an image or file block use the first permitted model here.'),
|
|
199
|
+
)}
|
|
200
|
+
{listField(
|
|
201
|
+
'toolModels',
|
|
202
|
+
t('Tool-calling models (in order)'),
|
|
203
|
+
t('Requests with tools/tool_choice use the first permitted model here.'),
|
|
204
|
+
)}
|
|
205
|
+
{listField(
|
|
206
|
+
'reasoningModels',
|
|
207
|
+
t('Reasoning models (in order)'),
|
|
208
|
+
t('Requests with an explicit reasoning or reasoning_effort parameter use the first permitted model here.'),
|
|
209
|
+
)}
|
|
210
|
+
{listField(
|
|
211
|
+
'cheapModels',
|
|
212
|
+
t('Cheap models (in order)'),
|
|
213
|
+
t('Optional. When set, cheap-eligible requests use the first permitted model here.'),
|
|
214
|
+
)}
|
|
215
|
+
{listField(
|
|
216
|
+
'generalModels',
|
|
217
|
+
t('General models (in order)'),
|
|
218
|
+
t(
|
|
219
|
+
'Default bucket when no capability rule matched. Leave empty to derive from all enabled models ordered by Model metadata sortOrder.',
|
|
220
|
+
),
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
224
|
+
<Switch />
|
|
225
|
+
</Form.Item>
|
|
226
|
+
|
|
227
|
+
<Space>
|
|
228
|
+
<Button type="primary" onClick={save} loading={saving}>
|
|
229
|
+
{t('Save')}
|
|
230
|
+
</Button>
|
|
231
|
+
<Text type="secondary">
|
|
232
|
+
{t('An empty bucket is derived automatically from Model metadata (capability flags + sortOrder).')}
|
|
233
|
+
</Text>
|
|
234
|
+
</Space>
|
|
235
|
+
</Form>
|
|
236
|
+
</Card>
|
|
237
|
+
);
|
|
238
|
+
}
|