plugin-ai-api 1.0.15 → 1.0.21

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 (92) hide show
  1. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  2. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  3. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  4. package/dist/client/757.a01403fb7a1bea01.js +10 -0
  5. package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
  6. package/dist/client/97.72979a11a067a7c9.js +10 -0
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  9. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  10. package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
  11. package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/97.29c663318eebbd57.js +10 -0
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +36 -0
  16. package/dist/externalVersion.js +9 -10
  17. package/dist/locale/en-US.json +105 -10
  18. package/dist/locale/vi-VN.json +105 -0
  19. package/dist/locale/zh-CN.json +105 -10
  20. package/dist/server/billing.js +331 -0
  21. package/dist/server/collections/ai-api-config.js +18 -0
  22. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  23. package/dist/server/collections/ai-api-model-prices.js +55 -0
  24. package/dist/server/collections/ai-api-usage-records.js +9 -0
  25. package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
  26. package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
  27. package/dist/server/plugin.js +36 -3
  28. package/dist/server/resource/ai-api-config.js +25 -0
  29. package/dist/server/resource/ai-api-usage-monitor.js +86 -0
  30. package/dist/server/routes/agent-completions.js +62 -51
  31. package/dist/server/routes/auth.js +11 -1
  32. package/dist/server/routes/chat-completions.js +157 -6
  33. package/dist/server/routes/completions.js +20 -3
  34. package/dist/server/routes/models.js +78 -20
  35. package/dist/server/routes/router.js +108 -23
  36. package/dist/server/usage.js +19 -2
  37. package/dist/server/utils/app-observability.js +110 -0
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/validation.js +120 -0
  40. package/dist/swagger.js +32 -1
  41. package/package.json +1 -1
  42. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  43. package/src/client/locale.ts +11 -21
  44. package/src/client/plugin.tsx +82 -48
  45. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  46. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  47. package/src/client-v2/locale.ts +21 -0
  48. package/src/client-v2/pages/GeneralPage.tsx +183 -0
  49. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  50. package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
  51. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  52. package/src/client-v2/pages/UsagePage.tsx +248 -0
  53. package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
  54. package/src/client-v2/pages/api.ts +16 -0
  55. package/src/client-v2/plugin.tsx +62 -4
  56. package/src/constants.ts +21 -0
  57. package/src/locale/en-US.json +105 -10
  58. package/src/locale/vi-VN.json +105 -0
  59. package/src/locale/zh-CN.json +105 -10
  60. package/src/server/__tests__/app-observability.test.ts +98 -0
  61. package/src/server/__tests__/billing-quota.test.ts +134 -0
  62. package/src/server/__tests__/billing.test.ts +33 -0
  63. package/src/server/__tests__/models.test.ts +74 -0
  64. package/src/server/__tests__/request-body.test.ts +310 -0
  65. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  66. package/src/server/__tests__/usage-monitor.test.ts +63 -0
  67. package/src/server/__tests__/usage-route.test.ts +4 -0
  68. package/src/server/billing.ts +387 -0
  69. package/src/server/collections/ai-api-config.ts +69 -51
  70. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  71. package/src/server/collections/ai-api-model-prices.ts +25 -0
  72. package/src/server/collections/ai-api-usage-records.ts +9 -0
  73. package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
  74. package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
  75. package/src/server/plugin.ts +47 -5
  76. package/src/server/resource/ai-api-config.ts +105 -74
  77. package/src/server/resource/ai-api-usage-monitor.ts +74 -0
  78. package/src/server/routes/agent-completions.ts +77 -62
  79. package/src/server/routes/auth.ts +14 -1
  80. package/src/server/routes/chat-completions.ts +275 -6
  81. package/src/server/routes/completions.ts +27 -4
  82. package/src/server/routes/models.ts +290 -195
  83. package/src/server/routes/router.ts +152 -27
  84. package/src/server/usage.ts +19 -1
  85. package/src/server/utils/app-observability.ts +105 -0
  86. package/src/server/utils/streaming.ts +13 -1
  87. package/src/server/validation.ts +89 -0
  88. package/src/swagger.ts +38 -1
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client/950.83390c5f1d5a97fb.js +0 -10
  91. package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
  92. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -21,9 +21,14 @@ import { checkRolePermission } from '../middleware/role-permission';
21
21
  import { startUsageRecord, finishUsageRecord } from '../usage';
22
22
  import { isStreamingRequested } from '../utils/streaming';
23
23
  import type PluginAiApiServer from '../plugin';
24
+ import { finalizeLlmBilling } from '../billing';
25
+ import { finishAiApiObservation, startAiApiObservation } from '../utils/app-observability';
24
26
 
25
27
  const API_PREFIX = '/api/ai-llm/v1';
26
28
 
29
+ /** Shared so the plugin can disable the core body parser for exactly these routes. */
30
+ export const AI_LLM_PREFIX = API_PREFIX;
31
+
27
32
  type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
28
33
 
29
34
  /**
@@ -73,7 +78,10 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
73
78
  ctx.set('Access-Control-Allow-Origin', '*');
74
79
  ctx.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
75
80
  ctx.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-AI-Mode, X-Timezone, X-Locale');
76
- ctx.set('Access-Control-Expose-Headers', 'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After');
81
+ ctx.set(
82
+ 'Access-Control-Expose-Headers',
83
+ 'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reason, Retry-After',
84
+ );
77
85
  ctx.set('Access-Control-Max-Age', '86400');
78
86
 
79
87
  // ─── OPTIONS preflight — return immediately after CORS headers ────────
@@ -86,22 +94,9 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
86
94
  const requestId = `req-${crypto.randomBytes(12).toString('hex')}`;
87
95
  ctx.set('X-Request-Id', requestId);
88
96
 
89
- // ─── Parse body for POST requests if not already parsed ───────────────
90
- if (method === 'POST' && !ctx.request.body) {
91
- try {
92
- const rawBody = await getRawBody(ctx);
93
- ctx.request.body = JSON.parse(rawBody);
94
- } catch (bodyErr: unknown) {
95
- const status =
96
- bodyErr && typeof bodyErr === 'object' && 'statusCode' in bodyErr && bodyErr.statusCode === 413 ? 413 : 400;
97
- const message = status === 413 ? 'Request body too large (max 10 MB)' : 'Invalid JSON in request body';
98
- ctx.status = status;
99
- ctx.body = toOpenAIError(status, message, 'invalid_request_error');
100
- return;
101
- }
102
- }
103
-
104
97
  // ─── Authenticate ─────────────────────────────────────────────────────
98
+ // Runs before the body is read: buffering a multi-megabyte payload for an
99
+ // anonymous caller would let unauthenticated traffic pin memory.
105
100
  const isAuth = await authenticateBearer(ctx);
106
101
  if (!isAuth) {
107
102
  logRequest(ctx, requestId, '-', 'auth_failed', 0);
@@ -122,6 +117,32 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
122
117
  return;
123
118
  }
124
119
 
120
+ // ─── Parse body for POST requests if not already parsed ───────────────
121
+ if (method === 'POST' && !ctx.request.body) {
122
+ const maxBodyBytes = await resolveMaxBodyBytes(ctx);
123
+ const declaredLength = Number(ctx.get('Content-Length'));
124
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
125
+ respondBodyTooLarge(ctx, maxBodyBytes);
126
+ logRequest(ctx, requestId, '-', 'body_too_large', 0);
127
+ return;
128
+ }
129
+ try {
130
+ const rawBody = await getRawBody(ctx, maxBodyBytes);
131
+ ctx.request.body = JSON.parse(rawBody);
132
+ } catch (bodyErr: unknown) {
133
+ const tooLarge =
134
+ bodyErr && typeof bodyErr === 'object' && 'statusCode' in bodyErr && bodyErr.statusCode === 413;
135
+ if (tooLarge) {
136
+ respondBodyTooLarge(ctx, maxBodyBytes);
137
+ logRequest(ctx, requestId, '-', 'body_too_large', 0);
138
+ return;
139
+ }
140
+ ctx.status = 400;
141
+ ctx.body = toOpenAIError(400, 'Invalid JSON in request body', 'invalid_request_error');
142
+ return;
143
+ }
144
+ }
145
+
125
146
  // ─── Route matching ───────────────────────────────────────────────────
126
147
  const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
127
148
  const model = requestBody.model === undefined || requestBody.model === null ? '-' : String(requestBody.model);
@@ -141,6 +162,23 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
141
162
  };
142
163
  }
143
164
  const t0 = Date.now();
165
+ if (isUsageEndpoint) {
166
+ const service =
167
+ subPath === '/embeddings'
168
+ ? 'llm.embedding'
169
+ : resolvedMode === 'agent'
170
+ ? 'llm.agent'
171
+ : subPath === '/completions'
172
+ ? 'llm.completion'
173
+ : 'llm.chat';
174
+ startAiApiObservation(ctx, {
175
+ service,
176
+ operation: subPath,
177
+ streaming,
178
+ model: model === '-' ? undefined : model,
179
+ mode: resolvedMode,
180
+ });
181
+ }
144
182
  let usageId: unknown;
145
183
  try {
146
184
  usageId = isUsageEndpoint
@@ -259,33 +297,120 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
259
297
  } catch (usageError) {
260
298
  ctx.log.error('AI API usage record could not be finalized:', usageError);
261
299
  }
300
+ } else if (ctx.state.aiApiLlmBilling) {
301
+ try {
302
+ const usageResult = ctx.state.aiApiUsageResult;
303
+ const providerUsage = usageResult?.source === 'provider' ? usageResult.usage : undefined;
304
+ const succeeded = ctx.state.aiApiStreamResult
305
+ ? ctx.state.aiApiStreamResult.succeeded
306
+ : ctx.status >= 200 && ctx.status < 400;
307
+ await finalizeLlmBilling(ctx, providerUsage, succeeded);
308
+ } catch (billingError) {
309
+ ctx.log.error('AI API quota reservation could not be finalized:', billingError);
310
+ }
311
+ }
312
+ if (isUsageEndpoint) {
313
+ const streamResult = ctx.state.aiApiStreamResult;
314
+ finishAiApiObservation(ctx, {
315
+ status:
316
+ streamResult?.errorCode === 'client_disconnected'
317
+ ? 'cancelled'
318
+ : streamResult
319
+ ? streamResult.succeeded
320
+ ? 'succeeded'
321
+ : 'failed'
322
+ : ctx.status >= 200 && ctx.status < 400
323
+ ? 'succeeded'
324
+ : ctx.status >= 500
325
+ ? 'failed'
326
+ : 'rejected',
327
+ errorCode: streamResult?.errorCode,
328
+ });
262
329
  }
263
330
  }
264
331
  };
265
332
  }
266
333
 
267
- /** Maximum allowed request body size (10 MB) to prevent OOM DoS attacks. */
268
- const MAX_BODY_BYTES = 10 * 1024 * 1024;
334
+ /** Body size cap used when aiApiConfig has no usable maxRequestBodyMb. */
335
+ const DEFAULT_MAX_BODY_MB = 10;
269
336
 
270
337
  /**
271
- * Read raw body from request stream (fallback if bodyparser didn't handle it).
272
- * Rejects with a 413-style error if the body exceeds MAX_BODY_BYTES.
338
+ * Upper bound an admin can configure.
339
+ *
340
+ * The gateway buffers the whole payload in memory (raw chunks, the decoded
341
+ * string, and the parsed object all coexist briefly), so an unbounded value
342
+ * would turn a config typo into an out-of-memory risk.
273
343
  */
274
- function getRawBody(ctx: Context): Promise<string> {
344
+ export const MAX_REQUEST_BODY_MB_LIMIT = 100;
345
+
346
+ /** Clamp a configured megabyte value into the supported range. */
347
+ export function normalizeMaxRequestBodyMb(value: unknown): number {
348
+ const mb = Number(value);
349
+ if (!Number.isSafeInteger(mb) || mb <= 0) return DEFAULT_MAX_BODY_MB;
350
+ return Math.min(mb, MAX_REQUEST_BODY_MB_LIMIT);
351
+ }
352
+
353
+ async function resolveMaxBodyBytes(ctx: Context): Promise<number> {
354
+ let configuredMb: unknown;
355
+ try {
356
+ const config = await ctx.db.getRepository('aiApiConfig').findOne();
357
+ configuredMb = config?.get('maxRequestBodyMb');
358
+ } catch (err) {
359
+ ctx.log?.warn?.('AI API: could not read maxRequestBodyMb, using default:', err);
360
+ }
361
+ return normalizeMaxRequestBodyMb(configuredMb) * 1024 * 1024;
362
+ }
363
+
364
+ function formatMb(bytes: number): string {
365
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
366
+ }
367
+
368
+ function respondBodyTooLarge(ctx: Context, maxBodyBytes: number): void {
369
+ ctx.status = 413;
370
+ ctx.body = toOpenAIError(
371
+ 413,
372
+ `Request body too large (max ${formatMb(maxBodyBytes)}). ` +
373
+ `Inline base64 images inflate payloads by ~33%; raise "Max request body size" in Settings → AI API Gateway if needed.`,
374
+ 'invalid_request_error',
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Read the raw request body.
380
+ *
381
+ * The plugin disables the core koa-bodyparser for these routes (see plugin.ts),
382
+ * so this cap is the one that actually governs gateway payload size.
383
+ * Rejects with a 413-style error once maxBodyBytes is exceeded.
384
+ *
385
+ * Exported so tests can drive the real implementation over a live socket rather
386
+ * than a copy that cannot catch a regression here.
387
+ */
388
+ export function getRawBody(ctx: Pick<Context, 'req'>, maxBodyBytes: number): Promise<string> {
275
389
  return new Promise((resolve, reject) => {
276
- let body = '';
390
+ const chunks: Buffer[] = [];
277
391
  let byteCount = 0;
392
+ let aborted = false;
278
393
 
279
394
  ctx.req.on('data', (chunk: Buffer) => {
395
+ if (aborted) return;
280
396
  byteCount += chunk.length;
281
- if (byteCount > MAX_BODY_BYTES) {
282
- ctx.req.destroy();
283
- reject(Object.assign(new Error('Request body too large (max 10 MB)'), { statusCode: 413 }));
397
+ if (byteCount > maxBodyBytes) {
398
+ // Drop what we buffered and drain the rest instead of destroying the
399
+ // socket: ctx.req and the response share one connection, so destroying
400
+ // it replaces our 413 JSON with an ECONNRESET on the client.
401
+ aborted = true;
402
+ chunks.length = 0;
403
+ ctx.req.resume();
404
+ reject(Object.assign(new Error(`Request body too large (max ${formatMb(maxBodyBytes)})`), { statusCode: 413 }));
284
405
  return;
285
406
  }
286
- body += chunk.toString();
407
+ chunks.push(chunk);
408
+ });
409
+ // Decode once at the end: a multi-byte UTF-8 character can straddle a chunk
410
+ // boundary, and per-chunk toString() would corrupt it.
411
+ ctx.req.on('end', () => {
412
+ if (!aborted) resolve(Buffer.concat(chunks).toString('utf8'));
287
413
  });
288
- ctx.req.on('end', () => resolve(body));
289
414
  ctx.req.on('error', reject);
290
415
  });
291
416
  }
@@ -1,4 +1,6 @@
1
1
  import { Context } from '@nocobase/actions';
2
+ import { finalizeLlmBilling, type LlmBillingState } from './billing';
3
+ import { addAiApiUsage } from './utils/app-observability';
2
4
 
3
5
  export type Usage = {
4
6
  prompt_tokens: number | null;
@@ -35,6 +37,7 @@ interface AiApiContextState {
35
37
  currentRoles?: string[];
36
38
  currentUser?: { id?: string | number | bigint };
37
39
  oauthPrincipal?: OAuthPrincipal;
40
+ aiApiLlmBilling?: LlmBillingState;
38
41
  }
39
42
 
40
43
  function getAiApiState(ctx: Context): AiApiContextState {
@@ -74,6 +77,7 @@ export function setAiApiUsageResult(
74
77
  getAiApiState(ctx).aiApiUsageResult = usage
75
78
  ? { source: 'provider', usage, ...metadata }
76
79
  : { source: 'unavailable', ...metadata };
80
+ addAiApiUsage(ctx, usage);
77
81
  return usage;
78
82
  }
79
83
 
@@ -158,7 +162,10 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
158
162
  const state = getAiApiState(ctx);
159
163
  const streamResult = state.aiApiStreamResult;
160
164
  const usageResult = state.aiApiUsageResult ?? { source: 'unavailable' as const };
161
- const usage = usageResult.source === 'provider' ? usageResult.usage : undefined;
165
+ const providerUsage = usageResult.source === 'provider' ? usageResult.usage : undefined;
166
+ const succeeded = streamResult ? streamResult.succeeded : status === 'succeeded';
167
+ const billing = await finalizeLlmBilling(ctx, providerUsage, succeeded);
168
+ const usage = billing.usage ?? providerUsage;
162
169
  const gatewayResponseId = usageResult.gatewayResponseId || response.id || streamResult?.id;
163
170
  const values = {
164
171
  status: streamResult ? (streamResult.succeeded ? 'succeeded' : 'failed') : status,
@@ -167,6 +174,17 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
167
174
  inputTokens: usage?.prompt_tokens ?? null,
168
175
  outputTokens: usage?.completion_tokens ?? null,
169
176
  totalTokens: usage?.total_tokens ?? null,
177
+ resolvedService: state.aiApiLlmBilling?.resolution?.service ?? null,
178
+ resolvedProvider: state.aiApiLlmBilling?.resolution?.provider ?? null,
179
+ resolvedModel: state.aiApiLlmBilling?.resolution?.model ?? null,
180
+ estimatedCost: billing.estimatedCost ?? null,
181
+ currency: billing.currency ?? null,
182
+ costStatus: billing.costStatus ?? null,
183
+ modelPriceId: billing.modelPriceId ?? null,
184
+ quotaPolicyId: billing.quotaPolicyId ?? null,
185
+ inputPricePerMillionTokens: billing.inputPricePerMillionTokens ?? null,
186
+ outputPricePerMillionTokens: billing.outputPricePerMillionTokens ?? null,
187
+ fixedCostPerRequest: billing.fixedCostPerRequest ?? null,
170
188
  providerRequestId: usageResult.providerRequestId ?? null,
171
189
  completedAt: new Date(),
172
190
  durationMs: Date.now() - startedAt,
@@ -0,0 +1,105 @@
1
+ import type { Context } from '@nocobase/actions';
2
+
3
+ interface ObservationFinish {
4
+ status: 'succeeded' | 'failed' | 'cancelled' | 'rejected';
5
+ errorCode?: string;
6
+ inputTokens?: number;
7
+ outputTokens?: number;
8
+ }
9
+ interface ObservationHandle {
10
+ markFirstByte(): void;
11
+ addInputTokens(value: number): void;
12
+ addOutputTokens(value: number): void;
13
+ finish(result: ObservationFinish): void;
14
+ }
15
+ interface AppObservabilityContract {
16
+ start(input: {
17
+ service: string;
18
+ operation: string;
19
+ streaming?: boolean;
20
+ attributes?: Record<string, string | number | boolean | null>;
21
+ }): ObservationHandle;
22
+ }
23
+ interface AiApiObservabilityState {
24
+ aiApiObservabilityHandle?: ObservationHandle;
25
+ aiApiObservabilityOutcome?: ObservationFinish;
26
+ }
27
+
28
+ const CONTRACT_SYMBOL = Symbol.for('nocobase.app-observability.contract');
29
+ const NOOP_HANDLE: ObservationHandle = {
30
+ markFirstByte() {},
31
+ addInputTokens() {},
32
+ addOutputTokens() {},
33
+ finish() {},
34
+ };
35
+
36
+ function state(ctx: Context): AiApiObservabilityState {
37
+ return ctx.state as AiApiObservabilityState;
38
+ }
39
+
40
+ function safely(ctx: Context, callback: () => void): void {
41
+ try {
42
+ callback();
43
+ } catch (error) {
44
+ ctx.app?.logger?.warn?.('[ai-api] App observability callback failed', { error });
45
+ }
46
+ }
47
+
48
+ export function startAiApiObservation(
49
+ ctx: Context,
50
+ input: {
51
+ service: 'llm.chat' | 'llm.agent' | 'llm.completion' | 'llm.embedding';
52
+ operation: string;
53
+ streaming: boolean;
54
+ model?: string;
55
+ mode: 'llm' | 'agent';
56
+ },
57
+ ): void {
58
+ let handle = NOOP_HANDLE;
59
+ safely(ctx, () => {
60
+ const contract = (ctx.app as object & { [CONTRACT_SYMBOL]?: AppObservabilityContract })[CONTRACT_SYMBOL];
61
+ if (!contract || typeof contract.start !== 'function') return;
62
+ const candidate = contract.start({
63
+ service: input.service,
64
+ operation: input.operation,
65
+ streaming: input.streaming,
66
+ attributes: {
67
+ mode: input.mode,
68
+ endpoint: input.operation,
69
+ ...(input.model ? { model: input.model } : {}),
70
+ },
71
+ });
72
+ if (candidate && typeof candidate.finish === 'function') handle = candidate;
73
+ });
74
+ state(ctx).aiApiObservabilityHandle = handle;
75
+ }
76
+
77
+ export function markAiApiFirstProviderOutput(ctx: Context): void {
78
+ safely(ctx, () => state(ctx).aiApiObservabilityHandle?.markFirstByte());
79
+ }
80
+
81
+ export function addAiApiUsage(
82
+ ctx: Context,
83
+ usage?: { prompt_tokens: number | null; completion_tokens: number | null },
84
+ ): void {
85
+ if (!usage) return;
86
+ safely(ctx, () => {
87
+ const handle = state(ctx).aiApiObservabilityHandle;
88
+ if (usage.prompt_tokens !== null) handle?.addInputTokens(usage.prompt_tokens);
89
+ if (usage.completion_tokens !== null) handle?.addOutputTokens(usage.completion_tokens);
90
+ });
91
+ }
92
+
93
+ export function setAiApiObservationOutcome(ctx: Context, outcome: ObservationFinish): void {
94
+ state(ctx).aiApiObservabilityOutcome = outcome;
95
+ }
96
+
97
+ export function finishAiApiObservation(ctx: Context, fallback: ObservationFinish): void {
98
+ const current = state(ctx);
99
+ const handle = current.aiApiObservabilityHandle;
100
+ if (!handle) return;
101
+ current.aiApiObservabilityHandle = undefined;
102
+ const outcome = current.aiApiObservabilityOutcome ?? fallback;
103
+ current.aiApiObservabilityOutcome = undefined;
104
+ safely(ctx, () => handle.finish(outcome));
105
+ }
@@ -1,5 +1,17 @@
1
1
  import type { Context } from '@nocobase/actions';
2
2
 
3
+ export class AiApiClientDisconnectedError extends Error {
4
+ readonly code = 'client_disconnected';
5
+ constructor() {
6
+ super('Client disconnected');
7
+ this.name = 'AiApiClientDisconnectedError';
8
+ }
9
+ }
10
+
11
+ export function isClientDisconnected(ctx: Context, error: unknown): boolean {
12
+ return ctx.req.aborted === true || error instanceof AiApiClientDisconnectedError;
13
+ }
14
+
3
15
  export function isStreamingRequested(value: unknown) {
4
16
  return value !== false;
5
17
  }
@@ -7,7 +19,7 @@ export function isStreamingRequested(value: unknown) {
7
19
  export function createRequestAbortController(ctx: Context) {
8
20
  const controller = new AbortController();
9
21
  const abort = () => {
10
- if (!ctx.res.writableEnded) controller.abort(new Error('Client disconnected'));
22
+ if (!ctx.res.writableEnded) controller.abort(new AiApiClientDisconnectedError());
11
23
  };
12
24
  ctx.req.once('aborted', abort);
13
25
  ctx.res.once('close', abort);
@@ -0,0 +1,89 @@
1
+ import dayjs from 'dayjs';
2
+ import type { Database, Model } from '@nocobase/database';
3
+
4
+ function requireNonNegativeDecimal(value: unknown, field: string): void {
5
+ const normalized = String(value ?? '').trim();
6
+ if (!/^\d+(?:\.\d+)?$/.test(normalized)) {
7
+ throw new Error(`${field} must be a non-negative decimal.`);
8
+ }
9
+ }
10
+
11
+ function requireNonNegativeIntegerOrNull(value: unknown, field: string): void {
12
+ if (value === null || value === undefined || value === '') return;
13
+ const parsed = Number(value);
14
+ if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${field} must be a non-negative integer.`);
15
+ }
16
+
17
+ export async function validateModelPrice(db: Database, model: Model): Promise<void> {
18
+ requireNonNegativeDecimal(model.get('inputPricePerMillionTokens'), 'inputPricePerMillionTokens');
19
+ requireNonNegativeDecimal(model.get('outputPricePerMillionTokens'), 'outputPricePerMillionTokens');
20
+ requireNonNegativeDecimal(model.get('fixedCostPerRequest') ?? 0, 'fixedCostPerRequest');
21
+
22
+ const effectiveFrom = new Date(String(model.get('effectiveFrom')));
23
+ const effectiveToValue = model.get('effectiveTo');
24
+ const effectiveTo = effectiveToValue ? new Date(String(effectiveToValue)) : undefined;
25
+ if (Number.isNaN(effectiveFrom.getTime())) throw new Error('effectiveFrom must be a valid date.');
26
+ if (effectiveTo && (Number.isNaN(effectiveTo.getTime()) || effectiveTo <= effectiveFrom)) {
27
+ throw new Error('effectiveTo must be later than effectiveFrom.');
28
+ }
29
+ if (model.get('enabled') === false) return;
30
+
31
+ const overlapFilter: Record<string, unknown> = {
32
+ llmService: model.get('llmService'),
33
+ model: model.get('model'),
34
+ enabled: true,
35
+ effectiveFrom: { $lt: effectiveTo ?? new Date('9999-12-31T23:59:59.999Z') },
36
+ $or: [{ effectiveTo: null }, { effectiveTo: { $gt: effectiveFrom } }],
37
+ };
38
+ if (model.get('id')) overlapFilter.id = { $ne: model.get('id') };
39
+ const overlap = await db.getRepository('aiApiModelPrices').findOne({
40
+ filter: overlapFilter,
41
+ });
42
+ if (overlap) throw new Error('An enabled price already overlaps this effective period.');
43
+ }
44
+
45
+ function requirePositiveIntegerOrNull(value: unknown, field: string): void {
46
+ if (value === null || value === undefined || value === '') return;
47
+ const parsed = Number(value);
48
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${field} must be a positive integer.`);
49
+ }
50
+
51
+ export function validateModelMetadata(model: Model): void {
52
+ if (!String(model.get('llmService') ?? '').trim()) throw new Error('llmService is required.');
53
+ if (!String(model.get('model') ?? '').trim()) throw new Error('model is required.');
54
+ requirePositiveIntegerOrNull(model.get('contextWindow'), 'contextWindow');
55
+ requirePositiveIntegerOrNull(model.get('maxCompletionTokens'), 'maxCompletionTokens');
56
+
57
+ const contextWindow = model.get('contextWindow');
58
+ const maxCompletionTokens = model.get('maxCompletionTokens');
59
+ if (
60
+ contextWindow !== null &&
61
+ contextWindow !== undefined &&
62
+ contextWindow !== '' &&
63
+ maxCompletionTokens !== null &&
64
+ maxCompletionTokens !== undefined &&
65
+ maxCompletionTokens !== '' &&
66
+ Number(maxCompletionTokens) > Number(contextWindow)
67
+ ) {
68
+ throw new Error('maxCompletionTokens cannot exceed contextWindow.');
69
+ }
70
+ }
71
+
72
+ export function validateQuotaPolicy(model: Model): void {
73
+ if (!['daily', 'monthly'].includes(String(model.get('periodType')))) {
74
+ throw new Error('periodType must be daily or monthly.');
75
+ }
76
+ if (!['allow', 'use_reserved'].includes(String(model.get('missingUsageBehavior')))) {
77
+ throw new Error('missingUsageBehavior must be allow or use_reserved.');
78
+ }
79
+ try {
80
+ dayjs().tz(String(model.get('timezone') || 'UTC'));
81
+ } catch {
82
+ throw new Error('timezone must be a valid IANA timezone.');
83
+ }
84
+ requireNonNegativeIntegerOrNull(model.get('requestLimit'), 'requestLimit');
85
+ requireNonNegativeIntegerOrNull(model.get('totalTokenLimit'), 'totalTokenLimit');
86
+ if (model.get('costLimit') !== null && model.get('costLimit') !== undefined) {
87
+ requireNonNegativeDecimal(model.get('costLimit'), 'costLimit');
88
+ }
89
+ }
package/src/swagger.ts CHANGED
@@ -258,6 +258,15 @@ export default {
258
258
  type: 'integer',
259
259
  description: 'Max requests per minute per user (0 = unlimited)',
260
260
  },
261
+ maxRequestBodyMb: {
262
+ type: 'integer',
263
+ minimum: 1,
264
+ maximum: 100,
265
+ default: 10,
266
+ description:
267
+ 'Max request body size in megabytes. Requests above this return 413. ' +
268
+ 'The gateway buffers each body in memory, so values above 100 are rejected.',
269
+ },
261
270
  },
262
271
  },
263
272
  ModelObject: {
@@ -269,11 +278,39 @@ export default {
269
278
  owned_by: { type: 'string' },
270
279
  },
271
280
  },
281
+ ContentBlock: {
282
+ type: 'object',
283
+ description:
284
+ 'A multimodal content block. Only text and image_url blocks are forwarded to the provider; ' +
285
+ 'any other type is rejected with 400 unsupported_content_block.',
286
+ properties: {
287
+ type: { type: 'string', enum: ['text', 'image_url'] },
288
+ text: { type: 'string' },
289
+ image_url: {
290
+ type: 'object',
291
+ properties: {
292
+ url: {
293
+ type: 'string',
294
+ description: 'An https URL or a base64 data URL, e.g. data:image/png;base64,iVBORw0KGgo...',
295
+ example: 'data:image/png;base64,iVBORw0KGgo...',
296
+ },
297
+ detail: { type: 'string', enum: ['auto', 'low', 'high'] },
298
+ },
299
+ required: ['url'],
300
+ },
301
+ },
302
+ required: ['type'],
303
+ },
272
304
  ChatMessage: {
273
305
  type: 'object',
274
306
  properties: {
275
307
  role: { type: 'string', enum: ['system', 'user', 'assistant', 'tool'] },
276
- content: { type: 'string' },
308
+ content: {
309
+ description:
310
+ 'Plain text, or an array of content blocks for multimodal requests. ' +
311
+ 'Inline base64 images inflate the payload by about 33%; see "Max request body size" in the gateway settings.',
312
+ oneOf: [{ type: 'string' }, { type: 'array', items: { $ref: '#/components/schemas/ContentBlock' } }],
313
+ },
277
314
  name: { type: 'string' },
278
315
  tool_call_id: { type: 'string' },
279
316
  tool_calls: { type: 'array', items: { $ref: '#/components/schemas/ToolCall' } },
@@ -1,10 +0,0 @@
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
- "use strict";(self.webpackChunkplugin_ai_api=self.webpackChunkplugin_ai_api||[]).push([["778"],{905:function(e,t,r){r.r(t),r.d(t,{AiApiRolePermissions:function(){return d}});var n=r(155),l=r.n(n),a=r(485),o=r(59);function i(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r<t;r++)n[r]=e[r];return n}function u(e,t,r,n,l,a,o){try{var i=e[a](o),u=i.value}catch(e){r(e);return}i.done?t(u):Promise.resolve(u).then(n,l)}function c(e){return function(){var t=this,r=arguments;return new Promise(function(n,l){var a=e.apply(t,r);function o(e){u(a,n,l,o,i,"next",e)}function i(e){u(a,n,l,o,i,"throw",e)}o(void 0)})}}function s(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{},n=Object.keys(r);"function"==typeof Object.getOwnPropertySymbols&&(n=n.concat(Object.getOwnPropertySymbols(r).filter(function(e){return Object.getOwnPropertyDescriptor(r,e).enumerable}))),n.forEach(function(t){var n;n=r[t],t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n})}return e}function p(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var r,n,l=null==e?null:"u">typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=l){var a=[],o=!0,i=!1;try{for(l=l.call(e);!(o=(r=l.next()).done)&&(a.push(r.value),!t||a.length!==t);o=!0);}catch(e){i=!0,n=e}finally{try{o||null==l.return||l.return()}finally{if(i)throw n}}return a}}(e,t)||function(e,t){if(e){if("string"==typeof e)return i(e,t);var r=Object.prototype.toString.call(e).slice(8,-1);if("Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r)return Array.from(r);if("Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return i(e,t)}}(e,t)||function(){throw TypeError("Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function f(e,t){var r,n,l,a={label:0,sent:function(){if(1&l[0])throw l[1];return l[1]},trys:[],ops:[]},o=Object.create(("function"==typeof Iterator?Iterator:Object).prototype),i=Object.defineProperty;return i(o,"next",{value:u(0)}),i(o,"throw",{value:u(1)}),i(o,"return",{value:u(2)}),"function"==typeof Symbol&&i(o,Symbol.iterator,{value:function(){return this}}),o;function u(i){return function(u){var c=[i,u];if(r)throw TypeError("Generator is already executing.");for(;o&&(o=0,c[0]&&(a=0)),a;)try{if(r=1,n&&(l=2&c[0]?n.return:c[0]?n.throw||((l=n.return)&&l.call(n),0):n.next)&&!(l=l.call(n,c[1])).done)return l;switch(n=0,l&&(c=[2&c[0],l.value]),c[0]){case 0:case 1:l=c;break;case 4:return a.label++,{value:c[1],done:!1};case 5:a.label++,n=c[1],c=[0];continue;case 7:c=a.ops.pop(),a.trys.pop();continue;default:if(!(l=(l=a.trys).length>0&&l[l.length-1])&&(6===c[0]||2===c[0])){a=0;continue}if(3===c[0]&&(!l||c[1]>l[0]&&c[1]<l[3])){a.label=c[1];break}if(6===c[0]&&a.label<l[1]){a.label=l[1],l=c;break}if(l&&a.label<l[2]){a.label=l[2],a.ops.push(c);break}l[2]&&a.ops.pop(),a.trys.pop();continue}c=t.call(e,a)}catch(e){c=[6,e],n=0}finally{r=l=0}if(5&c[0])throw c[1];return{value:c[0]?c[1]:void 0,done:!0}}}}var m=o.Typography.Text;function d(e){var t=e.role,r=(0,a.useApp)().apiClient,i=p((0,n.useState)(!0),2),u=i[0],d=i[1],y=p((0,n.useState)(!1),2),b=y[0],h=y[1],v=p((0,n.useState)([]),2),w=v[0],E=v[1],g=p((0,n.useState)(null),2),A=g[0],O=g[1],S=null==t?void 0:t.name;(0,n.useEffect)(function(){S&&P()},[S]);var P=function(){return c(function(){var e,t,n,l,a,o,i;return f(this,function(u){switch(u.label){case 0:d(!0),u.label=1;case 1:return u.trys.push([1,3,4,5]),[4,Promise.all([r.request({url:"aiApiRolePermissions",params:{filter:{roleName:S},paginate:!1}}),r.request({url:"aiEmployees:list",params:{paginate:!1}})])];case 2:return a=(l=p.apply(void 0,[u.sent(),2]))[0],o=l[1],O((i=null==a||null==(t=a.data)||null==(e=t.data)?void 0:e[0])?{id:i.id,roleName:i.roleName,enabled:!!i.enabled,allowAllEmployees:!1!==i.allowAllEmployees,allowedEmployees:i.allowedEmployees||[]}:{roleName:S,enabled:!1,allowAllEmployees:!0,allowedEmployees:[]}),E(((null==o||null==(n=o.data)?void 0:n.data)||[]).map(function(e){return{username:e.username,nickname:e.nickname||e.username}})),[3,5];case 3:return console.error("Failed to load AI API role permissions:",u.sent()),[3,5];case 4:return d(!1),[7];case 5:return[2]}})})()},j=function(e){return c(function(){var t,n,l,a;return f(this,function(o){switch(o.label){case 0:if(!A)return[2];O(t=s({},A,e)),h(!0),o.label=1;case 1:if(o.trys.push([1,6,7,8]),!t.id)return[3,3];return[4,r.request({url:"aiApiRolePermissions/".concat(t.id),method:"PUT",data:t})];case 2:return o.sent(),[3,5];case 3:return[4,r.request({url:"aiApiRolePermissions",method:"POST",data:t})];case 4:var i,u;(null==(a=null==(l=o.sent())||null==(n=l.data)?void 0:n.data)?void 0:a.id)&&O((i=s({},t),u=u={id:a.id},Object.getOwnPropertyDescriptors?Object.defineProperties(i,Object.getOwnPropertyDescriptors(u)):(function(e){var t=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t.push.apply(t,r)}return t})(Object(u)).forEach(function(e){Object.defineProperty(i,e,Object.getOwnPropertyDescriptor(u,e))}),i)),o.label=5;case 5:return[3,8];case 6:return console.error("Failed to save AI API role permissions:",o.sent()),O(A),[3,8];case 7:return h(!1),[7];case 8:return[2]}})})()};return u?l().createElement(o.Spin,null):l().createElement(o.Card,{bordered:!1},l().createElement(o.Space,{direction:"vertical",style:{width:"100%"},size:"middle"},l().createElement(o.Space,null,l().createElement(o.Switch,{checked:!!(null==A?void 0:A.enabled),loading:b,onChange:function(e){return j({enabled:e})}}),l().createElement(m,{strong:!0},"Allow this role to use the AI API")),(null==A?void 0:A.enabled)&&l().createElement(l().Fragment,null,l().createElement(o.Divider,{style:{margin:"8px 0"}}),l().createElement(o.Space,null,l().createElement(o.Switch,{checked:!!(null==A?void 0:A.allowAllEmployees),loading:b,onChange:function(e){return j({allowAllEmployees:e})}}),l().createElement(m,null,"Allow all AI Employees")),!(null==A?void 0:A.allowAllEmployees)&&l().createElement("div",null,l().createElement(m,{type:"secondary",style:{display:"block",marginBottom:8}},"Select which AI Employees this role may use:"),l().createElement(o.Select,{mode:"multiple",allowClear:!0,style:{width:"100%",maxWidth:480},placeholder:"Select allowed AI Employees",value:(null==A?void 0:A.allowedEmployees)||[],options:w.map(function(e){return{label:"".concat(e.nickname," (").concat(e.username,")"),value:e.username}}),onChange:function(e){return j({allowedEmployees:e})},disabled:b})))))}}}]);
@@ -1,10 +0,0 @@
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
- "use strict";(self.webpackChunkplugin_ai_api=self.webpackChunkplugin_ai_api||[]).push([["950"],{693:function(e,t,n){n.r(t),n.d(t,{AiApiConfigPage:function(){return y}});var a=n(155),r=n.n(a),l=n(59),o=n(485);function i(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,a=Array(t);n<t;n++)a[n]=e[n];return a}function c(e,t,n,a,r,l,o){try{var i=e[l](o),c=i.value}catch(e){n(e);return}i.done?t(c):Promise.resolve(c).then(a,r)}function u(e){return function(){var t=this,n=arguments;return new Promise(function(a,r){var l=e.apply(t,n);function o(e){c(l,a,r,o,i,"next",e)}function i(e){c(l,a,r,o,i,"throw",e)}o(void 0)})}}function s(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n,a,r=null==e?null:"u">typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=r){var l=[],o=!0,i=!1;try{for(r=r.call(e);!(o=(n=r.next()).done)&&(l.push(n.value),!t||l.length!==t);o=!0);}catch(e){i=!0,a=e}finally{try{o||null==r.return||r.return()}finally{if(i)throw a}}return l}}(e,t)||function(e,t){if(e){if("string"==typeof e)return i(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);if("Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n)return Array.from(n);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return i(e,t)}}(e,t)||function(){throw TypeError("Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function m(e,t){var n,a,r,l={label:0,sent:function(){if(1&r[0])throw r[1];return r[1]},trys:[],ops:[]},o=Object.create(("function"==typeof Iterator?Iterator:Object).prototype),i=Object.defineProperty;return i(o,"next",{value:c(0)}),i(o,"throw",{value:c(1)}),i(o,"return",{value:c(2)}),"function"==typeof Symbol&&i(o,Symbol.iterator,{value:function(){return this}}),o;function c(i){return function(c){var u=[i,c];if(n)throw TypeError("Generator is already executing.");for(;o&&(o=0,u[0]&&(l=0)),l;)try{if(n=1,a&&(r=2&u[0]?a.return:u[0]?a.throw||((r=a.return)&&r.call(a),0):a.next)&&!(r=r.call(a,u[1])).done)return r;switch(a=0,r&&(u=[2&u[0],r.value]),u[0]){case 0:case 1:r=u;break;case 4:return l.label++,{value:u[1],done:!1};case 5:l.label++,a=u[1],u=[0];continue;case 7:u=l.ops.pop(),l.trys.pop();continue;default:if(!(r=(r=l.trys).length>0&&r[r.length-1])&&(6===u[0]||2===u[0])){l=0;continue}if(3===u[0]&&(!r||u[1]>r[0]&&u[1]<r[3])){l.label=u[1];break}if(6===u[0]&&l.label<r[1]){l.label=r[1],r=u;break}if(r&&l.label<r[2]){l.label=r[2],l.ops.push(u);break}r[2]&&l.ops.pop(),l.trys.pop();continue}u=t.call(e,l)}catch(e){u=[6,e],a=0}finally{n=r=0}if(5&u[0])throw u[1];return{value:u[0]?u[1]:void 0,done:!0}}}}var p=l.Typography.Title,d=l.Typography.Text,f=l.Typography.Paragraph;function y(){var e=(0,o.useApp)().apiClient,t=s(l.Form.useForm(),1)[0],n=s((0,a.useState)(!0),2),i=n[0],c=n[1],y=s((0,a.useState)(!1),2),v=y[0],h=y[1],b=s((0,a.useState)([]),2),g=b[0],E=b[1],A=s((0,a.useState)([]),2),S=A[0],w=A[1],I=s((0,a.useState)(""),2),L=I[0],k=I[1];(0,a.useEffect)(function(){C(),k("".concat(window.location.origin,"/api/ai-llm/v1"))},[]);var C=function(){return u(function(){var n,a,r,l,o,i,u;return m(this,function(s){switch(s.label){case 0:c(!0),s.label=1;case 1:return s.trys.push([1,5,6,7]),[4,e.request({url:"aiApiConfig:get"})];case 2:return(o=null==(l=s.sent())||null==(n=l.data)?void 0:n.data)&&t.setFieldsValue({mode:o.mode||"llm",defaultAiEmployee:o.defaultAiEmployee||void 0,defaultLlmService:o.defaultLlmService||void 0,enabledLlmServices:o.enabledLlmServices||[],rateLimitPerMinute:o.rateLimitPerMinute||60}),[4,e.request({url:"aiEmployees:list",params:{paginate:!1}})];case 3:return E(((null==(i=s.sent())||null==(a=i.data)?void 0:a.data)||[]).map(function(e){return{username:e.username,nickname:e.nickname||e.username}})),[4,e.request({url:"ai:listLLMServices"})];case 4:return w(((null==(u=s.sent())||null==(r=u.data)?void 0:r.data)||[]).map(function(e){return{name:e.name,title:e.title||e.name,provider:e.provider,enabled:!1!==e.enabled}})),[3,7];case 5:return console.error("Failed to load config:",s.sent()),[3,7];case 6:return c(!1),[7];case 7:return[2]}})})()};return i?r().createElement("div",{style:{display:"flex",justifyContent:"center",padding:60}},r().createElement(l.Spin,{size:"large"})):r().createElement("div",{style:{maxWidth:800,margin:"0 auto",padding:"24px 0"}},r().createElement(p,{level:3},"AI API Gateway Configuration"),r().createElement(f,{type:"secondary"},"Configure the OpenAI-compatible API endpoint. External applications can connect using the base URL and a NocoBase API key."),r().createElement(l.Card,{style:{marginBottom:24}},r().createElement(l.Alert,{type:"info",showIcon:!0,message:"API Endpoint",description:r().createElement(l.Space,{direction:"vertical",size:4},r().createElement(d,null,"Base URL:"," ",r().createElement(d,{code:!0,copyable:!0},L)),r().createElement(d,{type:"secondary"},"Use this as the base URL in any OpenAI-compatible client (Cursor, Continue.dev, n8n, etc.)")),style:{marginBottom:16}}),r().createElement(l.Alert,{type:"warning",showIcon:!0,message:"Authentication",description:r().createElement(d,null,"Clients must include a NocoBase API key as a Bearer token:",r().createElement("br",null),r().createElement(d,{code:!0},"Authorization: Bearer ","<your-nocobase-api-key>"),r().createElement("br",null),r().createElement(d,{type:"secondary"},"API keys can be created in Settings → API keys."))})),r().createElement(l.Card,{title:"Configuration"},r().createElement(l.Form,{form:t,layout:"vertical",onFinish:function(){return u(function(){var n;return m(this,function(a){switch(a.label){case 0:h(!0),a.label=1;case 1:return a.trys.push([1,3,4,5]),n=t.getFieldsValue(),[4,e.request({url:"aiApiConfig:save",method:"post",data:n})];case 2:return a.sent(),l.message.success("Configuration saved"),[3,5];case 3:return a.sent(),l.message.error("Failed to save configuration"),[3,5];case 4:return h(!1),[7];case 5:return[2]}})})()},initialValues:{mode:"llm"}},r().createElement(l.Form.Item,{name:"mode",label:"API Mode",tooltip:"LLM Proxy: Direct access to the LLM model. Agent: Full AI Employee with tools, knowledge base, and agent capabilities."},r().createElement(l.Radio.Group,null,r().createElement(l.Radio.Button,{value:"llm"},"LLM Proxy"),r().createElement(l.Radio.Button,{value:"agent"},"AI Employee Agent"))),r().createElement(l.Form.Item,{name:"defaultAiEmployee",label:"Default AI Employee",tooltip:"The AI Employee whose system prompt will be injected into chat completions (when client doesn't provide a system message)"},r().createElement(l.Select,{allowClear:!0,placeholder:"Select an AI Employee (optional)",options:g.map(function(e){return{label:"".concat(e.nickname," (").concat(e.username,")"),value:e.username}})})),r().createElement(l.Form.Item,{name:"defaultLlmService",label:"Default LLM Service",tooltip:"The LLM service used when clients send only a model name (e.g. 'gpt-4o') without a service prefix. This is the main service that powers the API.",rules:[{required:!0,message:"Please select a default LLM service"}]},r().createElement(l.Select,{allowClear:!0,placeholder:"Select an LLM Service",options:S.map(function(e){return{label:"".concat(e.title," (").concat(e.provider,")"),value:e.name}})})),r().createElement(l.Form.Item,{name:"enabledLlmServices",label:"Enabled LLM Services",tooltip:"Only these services will be exposed via the API. Leave empty to expose all enabled services."},r().createElement(l.Select,{mode:"multiple",allowClear:!0,placeholder:"All enabled services (default)",options:S.map(function(e){return{label:"".concat(e.title," (").concat(e.provider,")"),value:e.name}})})),r().createElement(l.Form.Item,{name:"rateLimitPerMinute",label:"Rate Limit (requests/minute)",tooltip:"Maximum API requests per user per minute. Set 0 for unlimited."},r().createElement(l.InputNumber,{min:0,max:1e4,style:{width:200}})),r().createElement(l.Form.Item,null,r().createElement(l.Button,{type:"primary",htmlType:"submit",loading:v},"Save Configuration")))),r().createElement(l.Card,{title:"Quick Start",style:{marginTop:24}},r().createElement(p,{level:5},"cURL Example"),r().createElement(f,null,r().createElement("pre",{style:{background:"#1a1a2e",color:"#e0e0e0",padding:16,borderRadius:8,fontSize:13,overflow:"auto"}},"curl ".concat(L,'/chat/completions \\\n -H "Authorization: Bearer <your-api-key>" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "model": "<service-name>/<model-id>",\n "messages": [\n {"role": "user", "content": "Hello!"}\n ]\n }\''))),r().createElement(p,{level:5},"Python (OpenAI SDK)"),r().createElement(f,null,r().createElement("pre",{style:{background:"#1a1a2e",color:"#e0e0e0",padding:16,borderRadius:8,fontSize:13,overflow:"auto"}},'from openai import OpenAI\n\nclient = OpenAI(\n base_url="'.concat(L,'",\n api_key="<your-nocobase-api-key>"\n)\n\nresponse = client.chat.completions.create(\n model="<service-name>/<model-id>",\n messages=[{"role": "user", "content": "Hello!"}]\n)'))),r().createElement(p,{level:5},"List Available Models"),r().createElement(f,null,r().createElement("pre",{style:{background:"#1a1a2e",color:"#e0e0e0",padding:16,borderRadius:8,fontSize:13,overflow:"auto"}},"curl ".concat(L,'/models \\\n -H "Authorization: Bearer <your-api-key>"')))))}t.default=y}}]);