plugin-ai-api 1.0.13 → 1.0.15

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.
@@ -0,0 +1,222 @@
1
+ import { DataTypes } from '@nocobase/database';
2
+ import { Migration } from '@nocobase/server';
3
+ import { QueryTypes, type TableName } from 'sequelize';
4
+
5
+ const MAX_SIGNED_BIGINT = 9_223_372_036_854_775_807n;
6
+ const PREFLIGHT_BATCH_SIZE = 1_000;
7
+
8
+ interface LegacyUsageRow {
9
+ recordId: string | number | bigint;
10
+ userId: unknown;
11
+ }
12
+
13
+ interface InvalidUsageRow {
14
+ recordId: string;
15
+ userId: string;
16
+ }
17
+
18
+ interface InvalidUsageSummary {
19
+ count: number;
20
+ samples: InvalidUsageRow[];
21
+ }
22
+
23
+ function stringifyDatabaseValue(value: unknown): string {
24
+ if (typeof value === 'bigint') return value.toString();
25
+ if (value === null) return 'null';
26
+ if (value === undefined) return 'undefined';
27
+ return String(value);
28
+ }
29
+
30
+ export function normalizeLegacyUserId(value: unknown): string | undefined {
31
+ let normalized: string;
32
+
33
+ if (typeof value === 'string') {
34
+ normalized = value.trim();
35
+ } else if (typeof value === 'bigint') {
36
+ normalized = value.toString();
37
+ } else if (typeof value === 'number' && Number.isSafeInteger(value)) {
38
+ normalized = String(value);
39
+ } else {
40
+ return undefined;
41
+ }
42
+
43
+ if (!/^\d+$/.test(normalized)) return undefined;
44
+
45
+ const parsed = BigInt(normalized);
46
+ if (parsed > MAX_SIGNED_BIGINT) return undefined;
47
+
48
+ return normalized;
49
+ }
50
+
51
+ export function isBigIntColumnType(value: unknown): boolean {
52
+ return typeof value === 'string' && value.toUpperCase().includes('BIGINT');
53
+ }
54
+
55
+ export function isLegacyStringColumnType(value: unknown): boolean {
56
+ if (typeof value !== 'string') return false;
57
+ const normalized = value.toUpperCase();
58
+ return normalized.includes('CHAR') || normalized.includes('TEXT');
59
+ }
60
+
61
+ export default class ChangeUsageUserIdToBigInt extends Migration {
62
+ on = 'beforeLoad' as const;
63
+
64
+ async up() {
65
+ const { collection, temporary } = this.getUsageCollection();
66
+ try {
67
+ if (!(await collection.existsInDb())) return;
68
+
69
+ const userIdField = collection.getField('userId');
70
+ if (!userIdField) {
71
+ throw new Error('AI API usage migration could not resolve the userId field.');
72
+ }
73
+
74
+ const tableName = collection.getTableNameWithSchema();
75
+ const columnName = userIdField.columnName();
76
+ const columns = await this.queryInterface.describeTable(tableName);
77
+ const column = columns[columnName];
78
+ if (!column) {
79
+ throw new Error(`AI API usage migration could not find the physical column ${columnName}.`);
80
+ }
81
+
82
+ if (!isBigIntColumnType(column.type) && !isLegacyStringColumnType(column.type)) {
83
+ throw new Error(`AI API usage migration does not support converting ${column.type} to BIGINT.`);
84
+ }
85
+
86
+ const invalidRows = await this.findInvalidRows(collection.quotedTableName(), columnName);
87
+ if (invalidRows.count > 0) {
88
+ const samples = invalidRows.samples.map((row) => `${row.recordId}:${JSON.stringify(row.userId)}`).join(', ');
89
+ throw new Error(
90
+ `AI API usage migration found ${invalidRows.count} invalid userId value(s). ` +
91
+ `Clean these records before retrying the upgrade. Samples: ${samples}`,
92
+ );
93
+ }
94
+
95
+ if (isBigIntColumnType(column.type)) {
96
+ if (column.allowNull === false) return;
97
+ await this.makeColumnNotNull(tableName, collection.quotedTableName(), columnName);
98
+ return;
99
+ }
100
+
101
+ await this.changeColumnToBigInt(tableName, collection.quotedTableName(), columnName);
102
+ } finally {
103
+ if (temporary) this.db.removeCollection('aiApiUsageRecords');
104
+ }
105
+ }
106
+
107
+ async down() {
108
+ const { collection, temporary } = this.getUsageCollection();
109
+ try {
110
+ if (!(await collection.existsInDb())) return;
111
+
112
+ const userIdField = collection.getField('userId');
113
+ if (!userIdField) return;
114
+
115
+ const tableName = collection.getTableNameWithSchema();
116
+ const columnName = userIdField.columnName();
117
+ const columns = await this.queryInterface.describeTable(tableName);
118
+ if (!isBigIntColumnType(columns[columnName]?.type)) return;
119
+
120
+ if (this.db.inDialect('postgres')) {
121
+ const quotedColumn = this.db.quoteIdentifier(columnName);
122
+ await this.sequelize.transaction(async (transaction) => {
123
+ await this.sequelize.query(
124
+ `ALTER TABLE ${collection.quotedTableName()} ` +
125
+ `ALTER COLUMN ${quotedColumn} DROP NOT NULL, ` +
126
+ `ALTER COLUMN ${quotedColumn} TYPE VARCHAR(255) USING ${quotedColumn}::text`,
127
+ { transaction },
128
+ );
129
+ });
130
+ return;
131
+ }
132
+
133
+ await this.queryInterface.changeColumn(tableName, columnName, {
134
+ type: DataTypes.STRING,
135
+ allowNull: true,
136
+ });
137
+ } finally {
138
+ if (temporary) this.db.removeCollection('aiApiUsageRecords');
139
+ }
140
+ }
141
+
142
+ private getUsageCollection() {
143
+ const existing = this.db.getCollection('aiApiUsageRecords');
144
+ if (existing) return { collection: existing, temporary: false };
145
+
146
+ const collection = this.db.collection({
147
+ name: 'aiApiUsageRecords',
148
+ fields: [{ name: 'userId', type: 'string' }],
149
+ });
150
+ return { collection, temporary: true };
151
+ }
152
+
153
+ private async findInvalidRows(quotedTableName: string, columnName: string): Promise<InvalidUsageSummary> {
154
+ const quotedId = this.db.quoteIdentifier('id');
155
+ const quotedUserId = this.db.quoteIdentifier(columnName);
156
+ const invalidRows: InvalidUsageSummary = { count: 0, samples: [] };
157
+ let offset = 0;
158
+ let hasMoreRows = true;
159
+
160
+ while (hasMoreRows) {
161
+ const rows = await this.sequelize.query<LegacyUsageRow>(
162
+ `SELECT ${quotedId} AS ${this.db.quoteIdentifier('recordId')}, ` +
163
+ `${quotedUserId} AS ${this.db.quoteIdentifier('userId')} ` +
164
+ `FROM ${quotedTableName} ORDER BY ${quotedId} ASC LIMIT :limit OFFSET :offset`,
165
+ {
166
+ replacements: { limit: PREFLIGHT_BATCH_SIZE, offset },
167
+ type: QueryTypes.SELECT,
168
+ },
169
+ );
170
+
171
+ for (const row of rows) {
172
+ if (normalizeLegacyUserId(row.userId) === undefined) {
173
+ invalidRows.count += 1;
174
+ if (invalidRows.samples.length < 20) {
175
+ invalidRows.samples.push({
176
+ recordId: stringifyDatabaseValue(row.recordId),
177
+ userId: stringifyDatabaseValue(row.userId),
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ hasMoreRows = rows.length === PREFLIGHT_BATCH_SIZE;
184
+ offset += rows.length;
185
+ }
186
+
187
+ return invalidRows;
188
+ }
189
+
190
+ private async changeColumnToBigInt(tableName: TableName, quotedTableName: string, columnName: string) {
191
+ if (this.db.inDialect('postgres')) {
192
+ const quotedColumn = this.db.quoteIdentifier(columnName);
193
+ await this.sequelize.transaction(async (transaction) => {
194
+ await this.sequelize.query(
195
+ `ALTER TABLE ${quotedTableName} ` +
196
+ `ALTER COLUMN ${quotedColumn} TYPE BIGINT USING BTRIM(${quotedColumn})::BIGINT, ` +
197
+ `ALTER COLUMN ${quotedColumn} SET NOT NULL`,
198
+ { transaction },
199
+ );
200
+ });
201
+ return;
202
+ }
203
+
204
+ await this.queryInterface.changeColumn(tableName, columnName, {
205
+ type: DataTypes.BIGINT,
206
+ allowNull: false,
207
+ });
208
+ }
209
+
210
+ private async makeColumnNotNull(tableName: TableName, quotedTableName: string, columnName: string) {
211
+ if (this.db.inDialect('postgres')) {
212
+ const quotedColumn = this.db.quoteIdentifier(columnName);
213
+ await this.sequelize.query(`ALTER TABLE ${quotedTableName} ALTER COLUMN ${quotedColumn} SET NOT NULL`);
214
+ return;
215
+ }
216
+
217
+ await this.queryInterface.changeColumn(tableName, columnName, {
218
+ type: DataTypes.BIGINT,
219
+ allowNull: false,
220
+ });
221
+ }
222
+ }
@@ -25,6 +25,7 @@ import {
25
25
  getAgentRuntimeLifecycle,
26
26
  loadAIEmployeeConstructor,
27
27
  } from '../utils/ai-employee-runtime';
28
+ import { setAiApiUsageUnavailable } from '../usage';
28
29
  import type PluginAiApiServer from '../plugin';
29
30
 
30
31
  /**
@@ -395,8 +396,10 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
395
396
  ),
396
397
  );
397
398
  originalWrite(formatSSEDone());
399
+ setAiApiUsageUnavailable(ctx, completionId);
398
400
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
399
401
  } else {
402
+ setAiApiUsageUnavailable(ctx, completionId);
400
403
  ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'agent_error' };
401
404
  }
402
405
  if (!ctx.res.writableEnded && !ctx.res.destroyed) originalEnd();
@@ -443,6 +446,7 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
443
446
  // Clients can detect agent mode by checking usage.total_tokens === 0.
444
447
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
445
448
  };
449
+ setAiApiUsageUnavailable(ctx, completionId);
446
450
  }
447
451
 
448
452
  // ─── Cleanup: delete the ephemeral conversation (best-effort) ────────────
@@ -41,6 +41,7 @@ export async function authenticateBearer(ctx: Context): Promise<boolean> {
41
41
 
42
42
  try {
43
43
  if (ctx.state.currentUser) {
44
+ ctx.state.aiApiAuthType = ctx.state.oauthPrincipal ? 'oidc' : 'bearer';
44
45
  if (!ctx.state.currentRole) {
45
46
  const requestedRole = ctx.get('X-Role');
46
47
  const rolesRepository = ctx.db.getRepository('users.roles', ctx.state.currentUser.id);
@@ -21,6 +21,7 @@ import {
21
21
  import { resolveModelString } from '../utils/resolve-service';
22
22
  import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
23
23
  import { checkEmployeeAccess } from '../middleware/role-permission';
24
+ import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
24
25
  import type PluginAiApiServer from '../plugin';
25
26
 
26
27
  /**
@@ -249,13 +250,10 @@ async function handleNonStreamingCompletion(
249
250
  }
250
251
 
251
252
  // Extract usage if available
252
- const usage = result.usage_metadata
253
- ? {
254
- prompt_tokens: result.usage_metadata.input_tokens || 0,
255
- completion_tokens: result.usage_metadata.output_tokens || 0,
256
- total_tokens: result.usage_metadata.total_tokens || 0,
257
- }
258
- : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
253
+ const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
254
+ gatewayResponseId: completionId,
255
+ providerRequestId: extractProviderRequestId(result),
256
+ });
259
257
 
260
258
  ctx.status = 200;
261
259
  const toolCalls = normalizeToolCalls(result.tool_calls);
@@ -263,7 +261,7 @@ async function handleNonStreamingCompletion(
263
261
  id: completionId,
264
262
  model: modelName,
265
263
  content,
266
- usage,
264
+ usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
267
265
  toolCalls,
268
266
  });
269
267
  }
@@ -300,7 +298,8 @@ async function handleStreamingCompletion(
300
298
  );
301
299
 
302
300
  const requestAbort = createRequestAbortController(ctx);
303
- let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined;
301
+ let usage: Usage | undefined;
302
+ let providerRequestId: string | undefined;
304
303
  let finishReason = 'stop';
305
304
  try {
306
305
  const stream = await chatModel.stream(messages, { ...providerRequestParameters, signal: requestAbort.signal });
@@ -337,12 +336,9 @@ async function handleStreamingCompletion(
337
336
  );
338
337
  }
339
338
  if (chunk.usage_metadata) {
340
- usage = {
341
- prompt_tokens: chunk.usage_metadata.input_tokens || 0,
342
- completion_tokens: chunk.usage_metadata.output_tokens || 0,
343
- total_tokens: chunk.usage_metadata.total_tokens || 0,
344
- };
339
+ usage = normalizeUsage(chunk.usage_metadata) ?? usage;
345
340
  }
341
+ providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
346
342
  }
347
343
 
348
344
  // Send finish chunk
@@ -360,7 +356,8 @@ async function handleStreamingCompletion(
360
356
 
361
357
  // Send [DONE]
362
358
  await writeResponse(ctx, formatSSEDone());
363
- ctx.state.aiApiStreamResult = { succeeded: true, id: completionId, usage };
359
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
360
+ ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
364
361
  } catch (err) {
365
362
  ctx.log.error('AI API streaming error:', err);
366
363
  // Send error as SSE event before closing
@@ -375,7 +372,8 @@ async function handleStreamingCompletion(
375
372
  }),
376
373
  );
377
374
  }
378
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, usage, errorCode: 'stream_error' };
375
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
376
+ ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'stream_error' };
379
377
  } finally {
380
378
  requestAbort.dispose();
381
379
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -11,6 +11,7 @@ import { Context } from '@nocobase/actions';
11
11
  import { generateCompletionId, toOpenAIError, formatSSE, formatSSEDone } from '../utils/openai-format';
12
12
  import { resolveModelString } from '../utils/resolve-service';
13
13
  import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
14
+ import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
14
15
  import type PluginAiApiServer from '../plugin';
15
16
 
16
17
  /**
@@ -187,13 +188,10 @@ async function handleNonStreamingTextCompletion(
187
188
  text = textPart?.text || JSON.stringify(result.content);
188
189
  }
189
190
 
190
- const usage = result.usage_metadata
191
- ? {
192
- prompt_tokens: result.usage_metadata.input_tokens || 0,
193
- completion_tokens: result.usage_metadata.output_tokens || 0,
194
- total_tokens: result.usage_metadata.total_tokens || 0,
195
- }
196
- : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
191
+ const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
192
+ gatewayResponseId: completionId,
193
+ providerRequestId: extractProviderRequestId(result),
194
+ });
197
195
 
198
196
  ctx.status = 200;
199
197
  ctx.body = {
@@ -210,7 +208,7 @@ async function handleNonStreamingTextCompletion(
210
208
  finish_reason: 'stop',
211
209
  },
212
210
  ],
213
- usage,
211
+ usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
214
212
  };
215
213
  }
216
214
 
@@ -232,7 +230,8 @@ async function handleStreamingTextCompletion(
232
230
  ctx.status = 200;
233
231
 
234
232
  const requestAbort = createRequestAbortController(ctx);
235
- let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined;
233
+ let usage: Usage | undefined;
234
+ let providerRequestId: string | undefined;
236
235
  try {
237
236
  const stream = await chatModel.stream(messages, { signal: requestAbort.signal });
238
237
 
@@ -267,12 +266,9 @@ async function handleStreamingTextCompletion(
267
266
  );
268
267
  }
269
268
  if (chunk.usage_metadata) {
270
- usage = {
271
- prompt_tokens: chunk.usage_metadata.input_tokens || 0,
272
- completion_tokens: chunk.usage_metadata.output_tokens || 0,
273
- total_tokens: chunk.usage_metadata.total_tokens || 0,
274
- };
269
+ usage = normalizeUsage(chunk.usage_metadata) ?? usage;
275
270
  }
271
+ providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
276
272
  }
277
273
 
278
274
  // Final chunk
@@ -296,7 +292,8 @@ async function handleStreamingTextCompletion(
296
292
  );
297
293
 
298
294
  await writeResponse(ctx, formatSSEDone());
299
- ctx.state.aiApiStreamResult = { succeeded: true, id: completionId, usage };
295
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
296
+ ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
300
297
  } catch (err) {
301
298
  ctx.log.error('AI API completions streaming error:', err);
302
299
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
@@ -310,7 +307,8 @@ async function handleStreamingTextCompletion(
310
307
  }),
311
308
  );
312
309
  }
313
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, usage, errorCode: 'stream_error' };
310
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
311
+ ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'stream_error' };
314
312
  } finally {
315
313
  requestAbort.dispose();
316
314
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -10,6 +10,7 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
12
12
  import { resolveModelString } from '../utils/resolve-service';
13
+ import { setAiApiUsageUnavailable } from '../usage';
13
14
  import type PluginAiApiServer from '../plugin';
14
15
 
15
16
  /**
@@ -175,6 +176,7 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
175
176
 
176
177
  ctx.status = 200;
177
178
  ctx.set('Content-Type', 'application/json');
179
+ setAiApiUsageUnavailable(ctx);
178
180
  ctx.body = toOpenAIEmbeddingsResponse({
179
181
  model: body.model,
180
182
  embeddings: vectors,
@@ -24,6 +24,8 @@ import type PluginAiApiServer from '../plugin';
24
24
 
25
25
  const API_PREFIX = '/api/ai-llm/v1';
26
26
 
27
+ type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
28
+
27
29
  /**
28
30
  * Main Koa middleware router for OpenAI-compatible endpoints.
29
31
  *
@@ -60,7 +62,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
60
62
 
61
63
  // Prevent NocoBase's dataWrapping middleware from wrapping OpenAI-format responses
62
64
  // in an extra {"data": ...} envelope, which breaks OpenAI-compatible clients like n8n.
63
- (ctx as any).withoutDataWrapping = true;
65
+ (ctx as DataWrappingContext).withoutDataWrapping = true;
64
66
 
65
67
  // Parse the sub-path after prefix
66
68
  const subPath = path.substring(API_PREFIX.length);
@@ -89,8 +91,9 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
89
91
  try {
90
92
  const rawBody = await getRawBody(ctx);
91
93
  ctx.request.body = JSON.parse(rawBody);
92
- } catch (bodyErr: any) {
93
- const status = bodyErr?.statusCode === 413 ? 413 : 400;
94
+ } catch (bodyErr: unknown) {
95
+ const status =
96
+ bodyErr && typeof bodyErr === 'object' && 'statusCode' in bodyErr && bodyErr.statusCode === 413 ? 413 : 400;
94
97
  const message = status === 413 ? 'Request body too large (max 10 MB)' : 'Invalid JSON in request body';
95
98
  ctx.status = status;
96
99
  ctx.body = toOpenAIError(status, message, 'invalid_request_error');
@@ -120,13 +123,14 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
120
123
  }
121
124
 
122
125
  // ─── Route matching ───────────────────────────────────────────────────
123
- const model = (ctx.request.body as any)?.model ?? '-';
126
+ const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
127
+ const model = requestBody.model === undefined || requestBody.model === null ? '-' : String(requestBody.model);
124
128
  const isUsageEndpoint =
125
129
  method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions' || subPath === '/embeddings');
130
+ const isStreamingEndpoint = method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions');
126
131
  const resolvedMode = isUsageEndpoint ? await resolveMode(ctx) : 'llm';
127
- const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
128
- const streaming = isUsageEndpoint && isStreamingRequested(requestBody.stream);
129
- if (streaming && (subPath === '/chat/completions' || subPath === '/completions')) {
132
+ const streaming = isStreamingEndpoint && isStreamingRequested(requestBody.stream);
133
+ if (streaming) {
130
134
  const streamOptions = requestBody.stream_options;
131
135
  ctx.request.body = {
132
136
  ...requestBody,
@@ -140,7 +144,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
140
144
  let usageId: unknown;
141
145
  try {
142
146
  usageId = isUsageEndpoint
143
- ? await startUsageRecord(ctx, requestId, subPath, String(model), streaming, resolvedMode)
147
+ ? await startUsageRecord(ctx, requestId, subPath, model, streaming, resolvedMode)
144
148
  : undefined;
145
149
  } catch (usageError) {
146
150
  ctx.log.error('AI API usage record could not be created:', usageError);
@@ -172,8 +176,8 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
172
176
  const completionsMode = resolvedMode;
173
177
  if (completionsMode === 'agent') {
174
178
  // Convert legacy prompt → messages format for agent handler
175
- const reqBody = ctx.request.body as any;
176
- if (reqBody?.prompt !== undefined) {
179
+ const reqBody = (ctx.request.body || {}) as Record<string, unknown>;
180
+ if (reqBody.prompt !== undefined) {
177
181
  const prompt =
178
182
  typeof reqBody.prompt === 'string'
179
183
  ? reqBody.prompt