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.
- package/dist/externalVersion.js +10 -9
- package/dist/server/collections/ai-api-usage-records.js +9 -1
- package/dist/server/migrations/20260727140000-change-usage-user-id-to-bigint.js +208 -0
- package/dist/server/routes/agent-completions.js +4 -0
- package/dist/server/routes/auth.js +1 -0
- package/dist/server/routes/chat-completions.js +13 -13
- package/dist/server/routes/completions.js +13 -13
- package/dist/server/routes/embeddings.js +2 -0
- package/dist/server/routes/router.js +12 -11
- package/dist/server/usage.js +75 -21
- package/package.json +1 -1
- package/src/server/__tests__/usage-migration.test.ts +171 -0
- package/src/server/__tests__/usage-route.test.ts +105 -0
- package/src/server/__tests__/usage.test.ts +201 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -1
- package/src/server/migrations/20260727140000-change-usage-user-id-to-bigint.ts +222 -0
- package/src/server/routes/agent-completions.ts +4 -0
- package/src/server/routes/auth.ts +1 -0
- package/src/server/routes/chat-completions.ts +14 -16
- package/src/server/routes/completions.ts +14 -16
- package/src/server/routes/embeddings.ts +2 -0
- package/src/server/routes/router.ts +14 -10
- package/src/server/usage.ts +129 -28
- package/src/server/utils/openai-format.ts +6 -2
|
@@ -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
|
-
|
|
255
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
93
|
-
const status =
|
|
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
|
|
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
|
|
128
|
-
|
|
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,
|
|
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
|
|
176
|
-
if (reqBody
|
|
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
|