plugin-ai-api 1.0.20 → 1.0.23

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 (91) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  3. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  4. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  5. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  6. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  7. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  10. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  11. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  12. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  13. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  14. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  15. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  16. package/dist/client-v2/index.js +1 -1
  17. package/dist/constants.js +39 -0
  18. package/dist/externalVersion.js +9 -10
  19. package/dist/locale/en-US.json +39 -9
  20. package/dist/locale/vi-VN.json +31 -1
  21. package/dist/locale/zh-CN.json +31 -1
  22. package/dist/server/collections/ai-api-config.js +6 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  24. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  25. package/dist/server/plugin.js +45 -1
  26. package/dist/server/resource/ai-api-config.js +17 -0
  27. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  28. package/dist/server/routes/agent-completions.js +67 -51
  29. package/dist/server/routes/auth.js +11 -1
  30. package/dist/server/routes/chat-completions.js +174 -20
  31. package/dist/server/routes/completions.js +41 -21
  32. package/dist/server/routes/embeddings.js +6 -14
  33. package/dist/server/routes/models.js +102 -20
  34. package/dist/server/routes/router.js +94 -22
  35. package/dist/server/usage.js +2 -0
  36. package/dist/server/utils/app-observability.js +110 -0
  37. package/dist/server/utils/openai-format.js +17 -3
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/utils/user-permissions.js +160 -0
  40. package/dist/server/validation.js +18 -0
  41. package/dist/swagger.js +36 -4
  42. package/package.json +2 -2
  43. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  44. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  45. package/src/client/locale.ts +11 -21
  46. package/src/client/plugin.tsx +28 -8
  47. package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
  48. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  49. package/src/client-v2/locale.ts +21 -1
  50. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  51. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  52. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  53. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  54. package/src/client-v2/plugin.tsx +50 -1
  55. package/src/constants.ts +28 -0
  56. package/src/locale/en-US.json +39 -9
  57. package/src/locale/vi-VN.json +31 -1
  58. package/src/locale/zh-CN.json +31 -1
  59. package/src/server/__tests__/app-observability.test.ts +98 -0
  60. package/src/server/__tests__/models.test.ts +116 -0
  61. package/src/server/__tests__/openai-format.test.ts +52 -1
  62. package/src/server/__tests__/permission-sync.test.ts +109 -0
  63. package/src/server/__tests__/request-body.test.ts +310 -0
  64. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  65. package/src/server/__tests__/usage-route.test.ts +213 -0
  66. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  67. package/src/server/__tests__/user-permissions.test.ts +284 -0
  68. package/src/server/collections/ai-api-config.ts +6 -0
  69. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  70. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  71. package/src/server/plugin.ts +65 -4
  72. package/src/server/resource/ai-api-config.ts +23 -0
  73. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  74. package/src/server/routes/agent-completions.ts +84 -62
  75. package/src/server/routes/auth.ts +14 -1
  76. package/src/server/routes/chat-completions.ts +294 -20
  77. package/src/server/routes/completions.ts +54 -20
  78. package/src/server/routes/embeddings.ts +10 -15
  79. package/src/server/routes/models.ts +318 -195
  80. package/src/server/routes/router.ts +136 -26
  81. package/src/server/usage.ts +2 -0
  82. package/src/server/utils/app-observability.ts +105 -0
  83. package/src/server/utils/openai-format.ts +26 -0
  84. package/src/server/utils/streaming.ts +13 -1
  85. package/src/server/utils/user-permissions.ts +218 -0
  86. package/src/server/validation.ts +27 -0
  87. package/src/swagger.ts +47 -4
  88. package/dist/client/302.25edd5d75460acbf.js +0 -10
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  91. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -1,195 +1,318 @@
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 { Context } from '@nocobase/actions';
11
- import { toOpenAIError } from '../utils/openai-format';
12
- import type PluginAiApiServer from '../plugin';
13
-
14
- /**
15
- * GET /api/ai-llm/v1/models
16
- *
17
- * Lists all available models from enabled LLM services.
18
- * Model IDs use the "serviceName/modelId" format (e.g. "my-openai/gpt-4o")
19
- * so clients can copy-paste the ID directly into POST /v1/chat/completions
20
- * without needing to configure a defaultLlmService.
21
- *
22
- * Backward compatibility: resolveModelString() in resolve-service.ts still
23
- * accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
24
- */
25
- export async function handleListModels(ctx: Context, plugin: PluginAiApiServer) {
26
- try {
27
- const aiPlugin = ctx.app.pm.get('ai') as any;
28
- if (!aiPlugin) {
29
- ctx.status = 500;
30
- ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
31
- return;
32
- }
33
-
34
- const config = await getPluginConfig(ctx);
35
- const filter: any = {};
36
-
37
- // If whitelist is set, apply it (match by name OR title)
38
- if (config?.enabledLlmServices?.length) {
39
- filter.$or = [{ name: { $in: config.enabledLlmServices } }, { title: { $in: config.enabledLlmServices } }];
40
- }
41
-
42
- const services = await ctx.db.getRepository('llmServices').find({
43
- filter,
44
- sort: 'sort',
45
- });
46
-
47
- const now = Math.floor(Date.now() / 1000);
48
- const models: any[] = [];
49
-
50
- for (const service of services) {
51
- if (service.enabled === false) continue;
52
-
53
- const enabledModels = resolveEnabledModels(service);
54
- const serviceLabel = service.title || service.name;
55
-
56
- for (const model of enabledModels) {
57
- models.push({
58
- // Use "serviceName/modelId" format so the ID can be used directly in
59
- // POST /v1/chat/completions without ambiguity in multi-service setups.
60
- id: `${service.name}/${model.value}`,
61
- object: 'model',
62
- created: now,
63
- owned_by: serviceLabel,
64
- });
65
- }
66
- }
67
-
68
- ctx.status = 200;
69
- ctx.body = {
70
- object: 'list',
71
- data: models,
72
- };
73
- } catch (err) {
74
- ctx.log.error('AI API list models error:', err);
75
- ctx.status = 500;
76
- ctx.body = toOpenAIError(500, 'Failed to list models', 'server_error');
77
- }
78
- }
79
-
80
- /**
81
- * GET /api/ai-llm/v1/models/:model
82
- *
83
- * Retrieve a single model by ID.
84
- * Accepts both "serviceName/modelId" format (new) and bare "modelId" (backward compat).
85
- */
86
- export async function handleGetModel(ctx: Context, modelId: string, plugin: PluginAiApiServer) {
87
- try {
88
- const config = await getPluginConfig(ctx);
89
- const filter: any = {};
90
-
91
- if (config?.enabledLlmServices?.length) {
92
- filter.$or = [{ name: { $in: config.enabledLlmServices } }, { title: { $in: config.enabledLlmServices } }];
93
- }
94
-
95
- const services = await ctx.db.getRepository('llmServices').find({
96
- filter,
97
- sort: 'sort',
98
- });
99
-
100
- const now = Math.floor(Date.now() / 1000);
101
- let found: any = null;
102
-
103
- for (const service of services) {
104
- if (service.enabled === false) continue;
105
- const enabledModels = resolveEnabledModels(service);
106
- const serviceLabel = service.title || service.name;
107
-
108
- for (const model of enabledModels) {
109
- const fullId = `${service.name}/${model.value}`;
110
- // Accept both new "serviceName/modelId" format AND bare model ID (backward compat)
111
- if (fullId === modelId || model.value === modelId) {
112
- found = {
113
- id: fullId,
114
- object: 'model',
115
- created: now,
116
- owned_by: serviceLabel,
117
- };
118
- break;
119
- }
120
- }
121
- if (found) break;
122
- }
123
-
124
- if (!found) {
125
- ctx.status = 404;
126
- ctx.body = toOpenAIError(404, `Model '${modelId}' not found`, 'invalid_request_error', 'model_not_found');
127
- return;
128
- }
129
-
130
- ctx.status = 200;
131
- ctx.body = found;
132
- } catch (err) {
133
- ctx.log.error('AI API get model error:', err);
134
- ctx.status = 500;
135
- ctx.body = toOpenAIError(500, 'Failed to retrieve model', 'server_error');
136
- }
137
- }
138
-
139
- // ─── Helpers ───
140
-
141
- async function getPluginConfig(ctx: Context) {
142
- return ctx.db.getRepository('aiApiConfig').findOne();
143
- }
144
-
145
- /**
146
- * Resolve enabled models for a service.
147
- * Mirrors the logic from plugin-ai's ai.ts listAllEnabledModels.
148
- *
149
- * For 'recommended' mode, we now correctly fetch the official model list
150
- * from plugin-ai's shared recommended-models module.
151
- */
152
- function resolveEnabledModels(service: any): { label: string; value: string }[] {
153
- const raw = service.enabledModels;
154
-
155
- // Handle new { mode, models } format
156
- if (raw && typeof raw === 'object' && !Array.isArray(raw) && raw.mode) {
157
- if (raw.mode === 'recommended') {
158
- return getRecommendedModelsForProvider(service.provider);
159
- }
160
- // 'provider' or 'custom' mode with explicitly listed models
161
- return (raw.models || [])
162
- .filter((m: any) => m.value)
163
- .map((m: any) => ({ label: m.label || m.value, value: m.value }));
164
- }
165
-
166
- // Backward compat: old string[] format
167
- if (Array.isArray(raw)) {
168
- if (raw.length === 0) {
169
- // Empty array means no explicit models — fall back to recommended
170
- return getRecommendedModelsForProvider(service.provider);
171
- }
172
- return raw.map((id: string) => ({ label: id, value: id }));
173
- }
174
-
175
- // null/undefined — no explicit models set, fall back to recommended
176
- return getRecommendedModelsForProvider(service.provider);
177
- }
178
-
179
- /**
180
- * Get recommended models for a provider from plugin-ai's shared module.
181
- *
182
- * Uses dynamic require() to avoid a hard compile-time import path dependency.
183
- * plugin-ai is always available at runtime (it's a peerDependency).
184
- * Falls back to [] if the module is unavailable or the provider has no recommendations.
185
- */
186
- function getRecommendedModelsForProvider(provider: string): { label: string; value: string }[] {
187
- try {
188
- // eslint-disable-next-line @typescript-eslint/no-var-requires
189
- const { getRecommendedModels } = require('@nocobase/plugin-ai/src/common/recommended-models');
190
- const models = getRecommendedModels(provider);
191
- return Array.isArray(models) ? models : [];
192
- } catch {
193
- return [];
194
- }
195
- }
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 { Context } from '@nocobase/actions';
11
+ import { toOpenAIError } from '../utils/openai-format';
12
+ import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
13
+ import type PluginAiApiServer from '../plugin';
14
+
15
+ /**
16
+ * GET /api/ai-llm/v1/models
17
+ *
18
+ * Lists all available models from enabled LLM services.
19
+ * Model IDs use the "serviceName/modelId" format (e.g. "my-openai/gpt-4o")
20
+ * so clients can copy-paste the ID directly into POST /v1/chat/completions
21
+ * without needing to configure a defaultLlmService.
22
+ *
23
+ * The catalog is scoped to the caller: a user with an aiApiUserPermissions row only
24
+ * sees the intersection of the global whitelist and their own grant.
25
+ *
26
+ * Backward compatibility: resolveModelString() in resolve-service.ts still
27
+ * accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
28
+ */
29
+ export async function handleListModels(ctx: Context, plugin: PluginAiApiServer) {
30
+ try {
31
+ const aiPlugin = ctx.app.pm.get('ai') as any;
32
+ if (!aiPlugin) {
33
+ ctx.status = 500;
34
+ ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
35
+ return;
36
+ }
37
+
38
+ const config = await getPluginConfig(ctx);
39
+ const filter: any = {};
40
+
41
+ // If whitelist is set, apply it (match by name OR title)
42
+ if (config?.enabledLlmServices?.length) {
43
+ filter.$or = [{ name: { $in: config.enabledLlmServices } }, { title: { $in: config.enabledLlmServices } }];
44
+ }
45
+
46
+ const services = await ctx.db.getRepository('llmServices').find({
47
+ filter,
48
+ sort: 'sort',
49
+ });
50
+
51
+ const scope = await resolveUserAccessScope(ctx);
52
+ if (scope.lookupFailed) {
53
+ respondPermissionCheckFailed(ctx);
54
+ return;
55
+ }
56
+ const metadataMap = await loadModelMetadata(ctx);
57
+ const now = Math.floor(Date.now() / 1000);
58
+ const models: any[] = [];
59
+
60
+ for (const service of services) {
61
+ if (service.enabled === false) continue;
62
+ if (!isServiceAllowed(scope, config?.enabledLlmServices, service)) continue;
63
+
64
+ const enabledModels = resolveEnabledModels(service);
65
+ const serviceLabel = service.title || service.name;
66
+
67
+ for (const model of enabledModels) {
68
+ const fullId = `${service.name}/${model.value}`;
69
+ if (!isModelAllowed(scope, fullId)) continue;
70
+ const meta = metadataMap.get(fullId);
71
+ // An override row with enabled=false hides the model from the catalog.
72
+ if (meta && meta.enabled === false) continue;
73
+ models.push(buildModelObject(fullId, now, serviceLabel, meta));
74
+ }
75
+ }
76
+
77
+ ctx.status = 200;
78
+ ctx.body = {
79
+ object: 'list',
80
+ data: models,
81
+ };
82
+ } catch (err) {
83
+ ctx.log.error('AI API list models error:', err);
84
+ ctx.status = 500;
85
+ ctx.body = toOpenAIError(500, 'Failed to list models', 'server_error');
86
+ }
87
+ }
88
+
89
+ /**
90
+ * GET /api/ai-llm/v1/models/:model
91
+ *
92
+ * Retrieve a single model by ID.
93
+ * Accepts both "serviceName/modelId" format (new) and bare "modelId" (backward compat).
94
+ */
95
+ export async function handleGetModel(ctx: Context, modelId: string, plugin: PluginAiApiServer) {
96
+ try {
97
+ const config = await getPluginConfig(ctx);
98
+ const filter: any = {};
99
+
100
+ if (config?.enabledLlmServices?.length) {
101
+ filter.$or = [{ name: { $in: config.enabledLlmServices } }, { title: { $in: config.enabledLlmServices } }];
102
+ }
103
+
104
+ const services = await ctx.db.getRepository('llmServices').find({
105
+ filter,
106
+ sort: 'sort',
107
+ });
108
+
109
+ const scope = await resolveUserAccessScope(ctx);
110
+ if (scope.lookupFailed) {
111
+ respondPermissionCheckFailed(ctx);
112
+ return;
113
+ }
114
+ const metadataMap = await loadModelMetadata(ctx);
115
+ const now = Math.floor(Date.now() / 1000);
116
+ let found: any = null;
117
+
118
+ for (const service of services) {
119
+ if (service.enabled === false) continue;
120
+ if (!isServiceAllowed(scope, config?.enabledLlmServices, service)) continue;
121
+ const enabledModels = resolveEnabledModels(service);
122
+ const serviceLabel = service.title || service.name;
123
+
124
+ for (const model of enabledModels) {
125
+ const fullId = `${service.name}/${model.value}`;
126
+ // Accept both new "serviceName/modelId" format AND bare model ID (backward compat)
127
+ if (fullId === modelId || model.value === modelId) {
128
+ if (!isModelAllowed(scope, fullId)) continue;
129
+ const meta = metadataMap.get(fullId);
130
+ // A disabled override hides the model — treat as not found.
131
+ if (meta && meta.enabled === false) continue;
132
+ found = buildModelObject(fullId, now, serviceLabel, meta);
133
+ break;
134
+ }
135
+ }
136
+ if (found) break;
137
+ }
138
+
139
+ if (!found) {
140
+ ctx.status = 404;
141
+ ctx.body = toOpenAIError(404, `Model '${modelId}' not found`, 'invalid_request_error', 'model_not_found');
142
+ return;
143
+ }
144
+
145
+ ctx.status = 200;
146
+ ctx.body = found;
147
+ } catch (err) {
148
+ ctx.log.error('AI API get model error:', err);
149
+ ctx.status = 500;
150
+ ctx.body = toOpenAIError(500, 'Failed to retrieve model', 'server_error');
151
+ }
152
+ }
153
+
154
+ // ─── Helpers ───
155
+
156
+ function respondPermissionCheckFailed(ctx: Context): void {
157
+ ctx.status = 503;
158
+ ctx.body = toOpenAIError(
159
+ 503,
160
+ 'Unable to verify LLM permissions for this user. Please retry shortly.',
161
+ 'service_unavailable',
162
+ 'permission_check_failed',
163
+ );
164
+ }
165
+
166
+ export interface ModelMetadataOverride {
167
+ contextWindow?: number | null;
168
+ maxCompletionTokens?: number | null;
169
+ ownedByOverride?: string | null;
170
+ displayName?: string | null;
171
+ description?: string | null;
172
+ enabled?: boolean;
173
+ }
174
+
175
+ /**
176
+ * Load all model metadata overrides keyed by "serviceName/modelId".
177
+ * Returns an empty map if the collection is unavailable (e.g. mid-migration).
178
+ */
179
+ async function loadModelMetadata(ctx: Context): Promise<Map<string, ModelMetadataOverride>> {
180
+ const map = new Map<string, ModelMetadataOverride>();
181
+ try {
182
+ const rows = await ctx.db.getRepository('aiApiModelMetadata').find();
183
+ for (const row of rows) {
184
+ const service = row.get('llmService');
185
+ const model = row.get('model');
186
+ if (!service || !model) continue;
187
+ map.set(`${service}/${model}`, {
188
+ contextWindow: row.get('contextWindow'),
189
+ maxCompletionTokens: row.get('maxCompletionTokens'),
190
+ ownedByOverride: row.get('ownedByOverride'),
191
+ displayName: row.get('displayName'),
192
+ description: row.get('description'),
193
+ enabled: row.get('enabled'),
194
+ });
195
+ }
196
+ } catch (err) {
197
+ ctx.log?.warn?.('AI API model metadata unavailable, skipping overrides:', err);
198
+ }
199
+ return map;
200
+ }
201
+
202
+ /**
203
+ * Build an OpenAI-compatible model object, merging any admin override.
204
+ *
205
+ * Context window is emitted under BOTH keys for maximum client compatibility:
206
+ * - `context_window` (Groq / most OpenAI-compatible gateways)
207
+ * - `context_length` (OpenRouter and its consumers)
208
+ * Both carry the same value so a client reading either key sees the override.
209
+ */
210
+ export function buildModelObject(
211
+ fullId: string,
212
+ created: number,
213
+ serviceLabel: string,
214
+ meta?: ModelMetadataOverride,
215
+ ): Record<string, unknown> {
216
+ const model: Record<string, unknown> = {
217
+ id: fullId,
218
+ object: 'model',
219
+ created,
220
+ owned_by: meta?.ownedByOverride || serviceLabel,
221
+ };
222
+
223
+ const contextWindow = toPositiveInt(meta?.contextWindow);
224
+ if (contextWindow !== null) {
225
+ model.context_window = contextWindow;
226
+ model.context_length = contextWindow;
227
+ }
228
+ const maxCompletionTokens = toPositiveInt(meta?.maxCompletionTokens);
229
+ if (maxCompletionTokens !== null) {
230
+ model.max_completion_tokens = maxCompletionTokens;
231
+ }
232
+ if (meta?.displayName) {
233
+ model.display_name = meta.displayName;
234
+ model.name = meta.displayName;
235
+ }
236
+ if (meta?.description) {
237
+ model.description = meta.description;
238
+ }
239
+ // Only surface `active` when an override row exists; a plain model stays
240
+ // implicitly active (matching the previous response shape).
241
+ if (meta) {
242
+ model.active = meta.enabled !== false;
243
+ }
244
+
245
+ return model;
246
+ }
247
+
248
+ function toPositiveInt(value: unknown): number | null {
249
+ const n = Number(value);
250
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
251
+ }
252
+
253
+ async function getPluginConfig(ctx: Context) {
254
+ return ctx.db.getRepository('aiApiConfig').findOne();
255
+ }
256
+
257
+ /**
258
+ * Resolve enabled models for a service.
259
+ * Mirrors the logic from plugin-ai's ai.ts listAllEnabledModels.
260
+ *
261
+ * For 'recommended' mode, we now correctly fetch the official model list
262
+ * from plugin-ai's shared recommended-models module.
263
+ */
264
+ function resolveEnabledModels(service: any): { label: string; value: string }[] {
265
+ const raw = service.enabledModels;
266
+
267
+ // Handle new { mode, models } format
268
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && raw.mode) {
269
+ if (raw.mode === 'recommended') {
270
+ return getRecommendedModelsForProvider(service.provider);
271
+ }
272
+ // 'provider' or 'custom' mode with explicitly listed models
273
+ return (raw.models || [])
274
+ .filter((m: any) => m.value)
275
+ .map((m: any) => ({ label: m.label || m.value, value: m.value }));
276
+ }
277
+
278
+ // Backward compat: old string[] format
279
+ if (Array.isArray(raw)) {
280
+ if (raw.length === 0) {
281
+ // Empty array means no explicit models — fall back to recommended
282
+ return getRecommendedModelsForProvider(service.provider);
283
+ }
284
+ return raw.map((id: string) => ({ label: id, value: id }));
285
+ }
286
+
287
+ // null/undefined — no explicit models set, fall back to recommended
288
+ return getRecommendedModelsForProvider(service.provider);
289
+ }
290
+
291
+ /**
292
+ * Get recommended models for a provider from plugin-ai's shared module.
293
+ *
294
+ * Uses dynamic require() to avoid a hard compile-time import path dependency.
295
+ * plugin-ai is always available at runtime (it's a peerDependency).
296
+ *
297
+ * Resolves the built `dist/` module first — that is what ships in a packed
298
+ * plugin (package.json `main` points at `dist/`, and `src/` is not published).
299
+ * Falls back to `src/` for the monorepo dev runtime where only sources exist.
300
+ * Falls back to [] if neither is available or the provider has no recommendations.
301
+ */
302
+ function getRecommendedModelsForProvider(provider: string): { label: string; value: string }[] {
303
+ const modulePaths = [
304
+ '@nocobase/plugin-ai/dist/common/recommended-models',
305
+ '@nocobase/plugin-ai/src/common/recommended-models',
306
+ ];
307
+ for (const modulePath of modulePaths) {
308
+ try {
309
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
310
+ const { getRecommendedModels } = require(modulePath);
311
+ const models = getRecommendedModels(provider);
312
+ return Array.isArray(models) ? models : [];
313
+ } catch {
314
+ // Try the next candidate path.
315
+ }
316
+ }
317
+ return [];
318
+ }