plugin-ai-api 1.0.24 → 1.0.25
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/client/757.56952e321dc399b7.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +1 -0
- package/dist/locale/vi-VN.json +1 -0
- package/dist/locale/zh-CN.json +1 -0
- package/dist/server/billing.js +6 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- package/dist/server/plugin.js +10 -0
- package/dist/server/resource/ai-api-config.js +5 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/chat-completions.js +89 -10
- package/dist/server/routes/completions.js +32 -10
- package/dist/server/services/file-processor.js +262 -0
- package/dist/server/usage.js +33 -3
- package/dist/server/utils/direct-llm-context.js +150 -15
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/swagger.js +42 -3
- package/package.json +1 -1
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/locale/en-US.json +1 -0
- package/src/locale/vi-VN.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/server/__tests__/direct-llm-context.test.ts +87 -6
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/request-body.test.ts +45 -2
- package/src/server/__tests__/usage-route.test.ts +120 -3
- package/src/server/__tests__/usage.test.ts +19 -0
- package/src/server/billing.ts +6 -1
- package/src/server/collections/ai-api-config.ts +8 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +70 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- package/src/server/plugin.ts +20 -0
- package/src/server/resource/ai-api-config.ts +5 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/chat-completions.ts +134 -11
- package/src/server/routes/completions.ts +33 -7
- package/src/server/services/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +323 -0
- package/src/server/usage.ts +47 -1
- package/src/server/utils/direct-llm-context.ts +198 -20
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/resolve-service.ts +82 -82
- package/src/swagger.ts +45 -3
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
|
@@ -32,6 +32,7 @@ export default defineCollection({
|
|
|
32
32
|
{ name: 'inputTokens', type: 'integer', allowNull: true },
|
|
33
33
|
{ name: 'outputTokens', type: 'integer', allowNull: true },
|
|
34
34
|
{ name: 'totalTokens', type: 'integer', allowNull: true },
|
|
35
|
+
{ name: 'promptCacheTokens', type: 'integer', allowNull: true },
|
|
35
36
|
{ name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
|
|
36
37
|
{ name: 'currency', type: 'string', allowNull: true },
|
|
37
38
|
{ name: 'costStatus', type: 'string', allowNull: true, index: true },
|
package/src/server/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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
|
-
export { default } from './plugin';
|
|
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
|
+
export { default } from './plugin';
|
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file is part of the NocoBase (R) project.
|
|
3
|
-
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
-
* Authors: NocoBase Team.
|
|
5
|
-
*
|
|
6
|
-
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
-
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { Context } from '@nocobase/actions';
|
|
11
|
-
import { RateLimiter } from '../utils/rate-limiter';
|
|
12
|
-
import { toOpenAIError } from '../utils/openai-format';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Creates a rate-limiting check function for use in the AI API router.
|
|
16
|
-
*
|
|
17
|
-
* Must be called AFTER authenticateBearer() so ctx.state.currentUser is set.
|
|
18
|
-
* Reads rateLimitPerMinute from aiApiConfig on each request (cheap single-row read,
|
|
19
|
-
* allows config changes to take effect immediately without restart).
|
|
20
|
-
* Falls back to 60 req/min if the config record is missing or the field is 0/null.
|
|
21
|
-
*
|
|
22
|
-
* Returns false (and writes the 429 response) when the rate limit is exceeded.
|
|
23
|
-
* Returns true when the request is allowed.
|
|
24
|
-
*
|
|
25
|
-
* Sets OpenAI-compatible rate limit response headers on every request:
|
|
26
|
-
* X-RateLimit-Limit: <limit>
|
|
27
|
-
* X-RateLimit-Remaining: <remaining> (on 429: 0)
|
|
28
|
-
* Retry-After: <seconds> (on 429 only)
|
|
29
|
-
*/
|
|
30
|
-
export function createRateLimitMiddleware(limiter: RateLimiter) {
|
|
31
|
-
return async (ctx: Context): Promise<boolean> => {
|
|
32
|
-
const userId = ctx.state.currentUser?.id;
|
|
33
|
-
// Auth runs before this; if somehow missing, fail open (don't block the request).
|
|
34
|
-
if (userId === undefined || userId === null) return true;
|
|
35
|
-
|
|
36
|
-
let limit = 60;
|
|
37
|
-
try {
|
|
38
|
-
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
39
|
-
const configLimit = config?.rateLimitPerMinute;
|
|
40
|
-
if (configLimit && configLimit > 0) {
|
|
41
|
-
limit = configLimit;
|
|
42
|
-
}
|
|
43
|
-
} catch (configErr) {
|
|
44
|
-
// Config read failure: fail open — don't block legitimate requests
|
|
45
|
-
// Log at WARN so admins can detect DB connectivity issues
|
|
46
|
-
ctx.app?.logger?.warn('[ai-api] Rate limit config read failed, using default (60/min)', configErr);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const result = limiter.check(userId, limit);
|
|
50
|
-
|
|
51
|
-
if (!result.allowed) {
|
|
52
|
-
const retryAfterSec = Math.ceil((result as any).retryAfterMs / 1000);
|
|
53
|
-
ctx.set('Retry-After', String(retryAfterSec));
|
|
54
|
-
ctx.set('X-RateLimit-Limit', String(limit));
|
|
55
|
-
ctx.set('X-RateLimit-Remaining', '0');
|
|
56
|
-
ctx.status = 429;
|
|
57
|
-
ctx.body = toOpenAIError(
|
|
58
|
-
429,
|
|
59
|
-
`Rate limit exceeded. You have used all ${limit} requests allowed per minute. ` +
|
|
60
|
-
`Please wait ${retryAfterSec} second${retryAfterSec !== 1 ? 's' : ''} before retrying.`,
|
|
61
|
-
'requests',
|
|
62
|
-
'rate_limit_exceeded',
|
|
63
|
-
);
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
ctx.set('X-RateLimit-Limit', String(limit));
|
|
68
|
-
return true;
|
|
69
|
-
};
|
|
70
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context } from '@nocobase/actions';
|
|
11
|
+
import { RateLimiter } from '../utils/rate-limiter';
|
|
12
|
+
import { toOpenAIError } from '../utils/openai-format';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a rate-limiting check function for use in the AI API router.
|
|
16
|
+
*
|
|
17
|
+
* Must be called AFTER authenticateBearer() so ctx.state.currentUser is set.
|
|
18
|
+
* Reads rateLimitPerMinute from aiApiConfig on each request (cheap single-row read,
|
|
19
|
+
* allows config changes to take effect immediately without restart).
|
|
20
|
+
* Falls back to 60 req/min if the config record is missing or the field is 0/null.
|
|
21
|
+
*
|
|
22
|
+
* Returns false (and writes the 429 response) when the rate limit is exceeded.
|
|
23
|
+
* Returns true when the request is allowed.
|
|
24
|
+
*
|
|
25
|
+
* Sets OpenAI-compatible rate limit response headers on every request:
|
|
26
|
+
* X-RateLimit-Limit: <limit>
|
|
27
|
+
* X-RateLimit-Remaining: <remaining> (on 429: 0)
|
|
28
|
+
* Retry-After: <seconds> (on 429 only)
|
|
29
|
+
*/
|
|
30
|
+
export function createRateLimitMiddleware(limiter: RateLimiter) {
|
|
31
|
+
return async (ctx: Context): Promise<boolean> => {
|
|
32
|
+
const userId = ctx.state.currentUser?.id;
|
|
33
|
+
// Auth runs before this; if somehow missing, fail open (don't block the request).
|
|
34
|
+
if (userId === undefined || userId === null) return true;
|
|
35
|
+
|
|
36
|
+
let limit = 60;
|
|
37
|
+
try {
|
|
38
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
39
|
+
const configLimit = config?.rateLimitPerMinute;
|
|
40
|
+
if (configLimit && configLimit > 0) {
|
|
41
|
+
limit = configLimit;
|
|
42
|
+
}
|
|
43
|
+
} catch (configErr) {
|
|
44
|
+
// Config read failure: fail open — don't block legitimate requests
|
|
45
|
+
// Log at WARN so admins can detect DB connectivity issues
|
|
46
|
+
ctx.app?.logger?.warn('[ai-api] Rate limit config read failed, using default (60/min)', configErr);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = limiter.check(userId, limit);
|
|
50
|
+
|
|
51
|
+
if (!result.allowed) {
|
|
52
|
+
const retryAfterSec = Math.ceil((result as any).retryAfterMs / 1000);
|
|
53
|
+
ctx.set('Retry-After', String(retryAfterSec));
|
|
54
|
+
ctx.set('X-RateLimit-Limit', String(limit));
|
|
55
|
+
ctx.set('X-RateLimit-Remaining', '0');
|
|
56
|
+
ctx.status = 429;
|
|
57
|
+
ctx.body = toOpenAIError(
|
|
58
|
+
429,
|
|
59
|
+
`Rate limit exceeded. You have used all ${limit} requests allowed per minute. ` +
|
|
60
|
+
`Please wait ${retryAfterSec} second${retryAfterSec !== 1 ? 's' : ''} before retrying.`,
|
|
61
|
+
'requests',
|
|
62
|
+
'rate_limit_exceeded',
|
|
63
|
+
);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ctx.set('X-RateLimit-Limit', String(limit));
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Migration } from '@nocobase/server';
|
|
2
|
+
|
|
3
|
+
export default class AddPromptCacheTokensToUsageRecords extends Migration {
|
|
4
|
+
on = 'beforeLoad' as const;
|
|
5
|
+
|
|
6
|
+
async up() {
|
|
7
|
+
const collection = this.db.getCollection('aiApiUsageRecords');
|
|
8
|
+
if (!collection) return;
|
|
9
|
+
|
|
10
|
+
const field = collection.getField('promptCacheTokens');
|
|
11
|
+
if (!field) {
|
|
12
|
+
collection.addField('promptCacheTokens', { type: 'integer', allowNull: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (await collection.existsInDb()) {
|
|
16
|
+
const tableName = collection.getTableNameWithSchema();
|
|
17
|
+
const exists = await this.tableColumnExists(tableName, 'promptCacheTokens');
|
|
18
|
+
if (!exists) {
|
|
19
|
+
await this.queryInterface.addColumn(tableName, 'promptCacheTokens', {
|
|
20
|
+
type: this.db.sequelize.getDialect() === 'sqlite' ? 'INTEGER' : 'INTEGER',
|
|
21
|
+
allowNull: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async down() {
|
|
28
|
+
const collection = this.db.getCollection('aiApiUsageRecords');
|
|
29
|
+
if (!collection) return;
|
|
30
|
+
|
|
31
|
+
if (await collection.existsInDb()) {
|
|
32
|
+
const tableName = collection.getTableNameWithSchema();
|
|
33
|
+
const exists = await this.tableColumnExists(tableName, 'promptCacheTokens');
|
|
34
|
+
if (exists) {
|
|
35
|
+
await this.queryInterface.removeColumn(tableName, 'promptCacheTokens');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
collection.removeField('promptCacheTokens');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async tableColumnExists(tableName: string, columnName: string): Promise<boolean> {
|
|
43
|
+
const columns = await this.queryInterface.describeTable(tableName);
|
|
44
|
+
return Object.prototype.hasOwnProperty.call(columns, columnName);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/server/plugin.ts
CHANGED
|
@@ -18,6 +18,12 @@ import { invalidateRolePermissionCache } from './middleware/role-permission';
|
|
|
18
18
|
import { invalidateUserPermissionCache } from './utils/user-permissions';
|
|
19
19
|
import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
|
|
20
20
|
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
|
|
21
|
+
import {
|
|
22
|
+
FileProcessorService,
|
|
23
|
+
base64FileForwarder,
|
|
24
|
+
httpFileUrlFetcher,
|
|
25
|
+
pdfFileProcessor,
|
|
26
|
+
} from './services/file-processor';
|
|
21
27
|
|
|
22
28
|
// Ensure dayjs timezone + utc plugins are loaded.
|
|
23
29
|
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
@@ -39,6 +45,12 @@ export class PluginAiApiServer extends Plugin {
|
|
|
39
45
|
*/
|
|
40
46
|
rateLimiter = new RateLimiter(60_000);
|
|
41
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Extensible file processor service. Other plugins can register custom processors
|
|
50
|
+
* to transform file/file_url content blocks before they reach the LLM.
|
|
51
|
+
*/
|
|
52
|
+
fileProcessorService = new FileProcessorService();
|
|
53
|
+
|
|
42
54
|
private gcInterval: NodeJS.Timeout | null = null;
|
|
43
55
|
|
|
44
56
|
async afterAdd() {}
|
|
@@ -56,6 +68,13 @@ export class PluginAiApiServer extends Plugin {
|
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
async load() {
|
|
71
|
+
// Register default file processors. Custom plugins can register additional
|
|
72
|
+
// processors by retrieving this plugin instance and calling
|
|
73
|
+
// `fileProcessorService.register(processor)`.
|
|
74
|
+
this.fileProcessorService.register(base64FileForwarder);
|
|
75
|
+
this.fileProcessorService.register(httpFileUrlFetcher);
|
|
76
|
+
this.fileProcessorService.register(pdfFileProcessor);
|
|
77
|
+
|
|
59
78
|
// 1. Claim body parsing for our own routes before the core bodyParser runs.
|
|
60
79
|
// Core registers koa-bodyparser with a global REQUEST_BODY_LIMIT (10mb by
|
|
61
80
|
// default) much earlier in the stack, so without this the gateway's own
|
|
@@ -159,6 +178,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
159
178
|
enabledLlmServices: [],
|
|
160
179
|
rateLimitPerMinute: 60,
|
|
161
180
|
quotaEnabled: false,
|
|
181
|
+
pdfRenderPagesAsImages: false,
|
|
162
182
|
defaultReservationOutputTokens: 4096,
|
|
163
183
|
},
|
|
164
184
|
});
|
|
@@ -48,6 +48,7 @@ const aiApiConfigResource: ResourceOptions = {
|
|
|
48
48
|
enabledLlmServices: [],
|
|
49
49
|
rateLimitPerMinute: 60,
|
|
50
50
|
maxRequestBodyMb: 10,
|
|
51
|
+
pdfRenderPagesAsImages: false,
|
|
51
52
|
quotaEnabled: false,
|
|
52
53
|
defaultReservationOutputTokens: 4096,
|
|
53
54
|
options: {},
|
|
@@ -72,6 +73,7 @@ const aiApiConfigResource: ResourceOptions = {
|
|
|
72
73
|
enabledLlmServices: values.enabledLlmServices ?? [],
|
|
73
74
|
rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
|
|
74
75
|
maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
|
|
76
|
+
pdfRenderPagesAsImages: values.pdfRenderPagesAsImages ?? false,
|
|
75
77
|
quotaEnabled: values.quotaEnabled ?? false,
|
|
76
78
|
defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
|
|
77
79
|
options: values.options ?? {},
|
|
@@ -87,6 +89,9 @@ const aiApiConfigResource: ResourceOptions = {
|
|
|
87
89
|
if (values.maxRequestBodyMb !== undefined) {
|
|
88
90
|
updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
|
|
89
91
|
}
|
|
92
|
+
if (values.pdfRenderPagesAsImages !== undefined) {
|
|
93
|
+
updateData.pdfRenderPagesAsImages = Boolean(values.pdfRenderPagesAsImages);
|
|
94
|
+
}
|
|
90
95
|
if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
|
|
91
96
|
if (values.defaultReservationOutputTokens !== undefined) {
|
|
92
97
|
updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
|
|
@@ -7,6 +7,7 @@ interface UsageSummaryRow {
|
|
|
7
7
|
inputTokens?: string | number;
|
|
8
8
|
outputTokens?: string | number;
|
|
9
9
|
totalTokens?: string | number;
|
|
10
|
+
promptCacheTokens?: string | number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface CostSummaryRow {
|
|
@@ -45,6 +46,7 @@ const aiApiUsageMonitorResource: ResourceOptions = {
|
|
|
45
46
|
[fn('COALESCE', fn('SUM', col('inputTokens')), 0), 'inputTokens'],
|
|
46
47
|
[fn('COALESCE', fn('SUM', col('outputTokens')), 0), 'outputTokens'],
|
|
47
48
|
[fn('COALESCE', fn('SUM', col('totalTokens')), 0), 'totalTokens'],
|
|
49
|
+
[fn('COALESCE', fn('SUM', col('promptCacheTokens')), 0), 'promptCacheTokens'],
|
|
48
50
|
],
|
|
49
51
|
where,
|
|
50
52
|
raw: true,
|
|
@@ -61,6 +63,7 @@ const aiApiUsageMonitorResource: ResourceOptions = {
|
|
|
61
63
|
inputTokens: Number(totals?.inputTokens ?? 0),
|
|
62
64
|
outputTokens: Number(totals?.outputTokens ?? 0),
|
|
63
65
|
totalTokens: Number(totals?.totalTokens ?? 0),
|
|
66
|
+
promptCacheTokens: Number(totals?.promptCacheTokens ?? 0),
|
|
64
67
|
costsByCurrency: costs.map((item) => ({
|
|
65
68
|
currency: item.currency || 'USD',
|
|
66
69
|
totalCost: String(item.totalCost ?? 0),
|
|
@@ -33,6 +33,7 @@ import type PluginAiApiServer from '../plugin';
|
|
|
33
33
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
34
34
|
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
35
35
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
36
|
+
import { FileContentBlock, FileProcessorError } from '../services/file-processor';
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* POST /api/ai-llm/v1/chat/completions
|
|
@@ -195,6 +196,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
195
196
|
messages.unshift({ role: 'system', content: systemPrompt });
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
// ─── Process file / file_url blocks through the file processor service ───
|
|
200
|
+
messages = await Promise.all(
|
|
201
|
+
messages.map(async (msg) => ({
|
|
202
|
+
...msg,
|
|
203
|
+
content: await processMessageContentFileBlocks(msg.content, ctx, plugin),
|
|
204
|
+
})),
|
|
205
|
+
);
|
|
206
|
+
|
|
198
207
|
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
199
208
|
serviceName: service.name,
|
|
200
209
|
modelId,
|
|
@@ -271,13 +280,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
271
280
|
if (!ctx.res?.headersSent) {
|
|
272
281
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
273
282
|
const isContextError = err instanceof DirectLlmContextError;
|
|
274
|
-
|
|
283
|
+
const isFileError = err instanceof FileProcessorError;
|
|
284
|
+
ctx.status = isQuotaError ? 429 : isContextError || isFileError ? 400 : 500;
|
|
275
285
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
276
286
|
ctx.body = toOpenAIError(
|
|
277
287
|
ctx.status,
|
|
278
288
|
getErrorMessage(err, 'Internal server error'),
|
|
279
|
-
isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
|
|
280
|
-
isQuotaError || isContextError ? err.code : undefined,
|
|
289
|
+
isQuotaError ? 'quota_error' : isContextError || isFileError ? 'invalid_request_error' : 'server_error',
|
|
290
|
+
isQuotaError || isContextError || isFileError ? err.code : undefined,
|
|
281
291
|
);
|
|
282
292
|
}
|
|
283
293
|
}
|
|
@@ -305,10 +315,15 @@ async function handleNonStreamingCompletion(
|
|
|
305
315
|
}
|
|
306
316
|
|
|
307
317
|
// Extract usage if available
|
|
308
|
-
const usage = setAiApiUsageResult(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
318
|
+
const usage = setAiApiUsageResult(
|
|
319
|
+
ctx,
|
|
320
|
+
result.usage_metadata,
|
|
321
|
+
{
|
|
322
|
+
gatewayResponseId: completionId,
|
|
323
|
+
providerRequestId: extractProviderRequestId(result),
|
|
324
|
+
},
|
|
325
|
+
result.response_metadata,
|
|
326
|
+
);
|
|
312
327
|
|
|
313
328
|
ctx.status = 200;
|
|
314
329
|
const toolCalls = normalizeToolCalls(result.tool_calls);
|
|
@@ -475,7 +490,7 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
475
490
|
* the model answers as if the attachment was never sent. A 400 is far easier to
|
|
476
491
|
* debug than a confidently wrong completion.
|
|
477
492
|
*/
|
|
478
|
-
const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
|
|
493
|
+
const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url', 'file', 'file_url']);
|
|
479
494
|
|
|
480
495
|
/**
|
|
481
496
|
* Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
|
|
@@ -486,6 +501,14 @@ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
|
|
|
486
501
|
*/
|
|
487
502
|
const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
488
503
|
|
|
504
|
+
/**
|
|
505
|
+
* File blocks accept a wider range of MIME types than `image_url` blocks:
|
|
506
|
+
* documents such as `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
|
507
|
+
* or `image/svg+xml` are valid attachments. The grammar still requires a proper
|
|
508
|
+
* `type/subtype` and standard base64 payload.
|
|
509
|
+
*/
|
|
510
|
+
const FILE_BASE64_DATA_URL_PATTERN = /^data:([^;\s]+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
511
|
+
|
|
489
512
|
/**
|
|
490
513
|
* The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
|
|
491
514
|
* `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
|
|
@@ -591,8 +614,8 @@ function describeContentBlockProblem(block: unknown): string | undefined {
|
|
|
591
614
|
if (!type) return "each content block requires a 'type' field";
|
|
592
615
|
if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
|
|
593
616
|
return (
|
|
594
|
-
`content block type '${type}' is not supported — this gateway forwards 'text'
|
|
595
|
-
`Send documents as text, or inline them as
|
|
617
|
+
`content block type '${type}' is not supported — this gateway forwards 'text', 'image_url', ` +
|
|
618
|
+
`'file', and 'file_url' only. Send documents as text, or inline them as a 'file' / 'file_url' block`
|
|
596
619
|
);
|
|
597
620
|
}
|
|
598
621
|
|
|
@@ -600,9 +623,56 @@ function describeContentBlockProblem(block: unknown): string | undefined {
|
|
|
600
623
|
return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
|
|
601
624
|
}
|
|
602
625
|
|
|
626
|
+
if (type === 'file') {
|
|
627
|
+
return describeFileProblem(block.file);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (type === 'file_url') {
|
|
631
|
+
return describeFileUrlProblem(block.file_url);
|
|
632
|
+
}
|
|
633
|
+
|
|
603
634
|
return describeImageUrlProblem(block.image_url);
|
|
604
635
|
}
|
|
605
636
|
|
|
637
|
+
function describeFileProblem(file: unknown): string | undefined {
|
|
638
|
+
if (!isRecord(file)) return "a 'file' block requires an object 'file' field";
|
|
639
|
+
const fileData = typeof file.file_data === 'string' ? file.file_data : undefined;
|
|
640
|
+
if (!fileData) return "a 'file' block requires a string 'file.file_data' field";
|
|
641
|
+
if (!fileData.startsWith('data:')) {
|
|
642
|
+
return "a 'file' block's 'file_data' must be a base64 data URL starting with 'data:'";
|
|
643
|
+
}
|
|
644
|
+
const match = FILE_BASE64_DATA_URL_PATTERN.exec(fileData);
|
|
645
|
+
if (!match || !match[1].includes('/')) {
|
|
646
|
+
return (
|
|
647
|
+
`malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' ` +
|
|
648
|
+
`with a valid type/subtype and standard base64 (no whitespace or URL-safe characters)`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (!isDecodableBase64(match[2])) {
|
|
652
|
+
return (
|
|
653
|
+
`base64 payload is not decodable. Check the padding and length — ` +
|
|
654
|
+
`the data must be a multiple of 4 characters with at most two trailing '='`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function describeFileUrlProblem(fileUrl: unknown): string | undefined {
|
|
661
|
+
if (!isRecord(fileUrl)) return "a 'file_url' block requires an object 'file_url' field";
|
|
662
|
+
const url = typeof fileUrl.url === 'string' ? fileUrl.url : undefined;
|
|
663
|
+
if (!url) return "a 'file_url' block requires a string 'file_url.url' field";
|
|
664
|
+
let protocol: string;
|
|
665
|
+
try {
|
|
666
|
+
protocol = new URL(url).protocol;
|
|
667
|
+
} catch {
|
|
668
|
+
return `'${url}' is not a valid URL. Use an http(s) URL`;
|
|
669
|
+
}
|
|
670
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
671
|
+
return `URL protocol '${protocol}' is not supported. Use an http(s) URL`;
|
|
672
|
+
}
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
|
|
606
676
|
function describeImageUrlProblem(imageUrl: unknown): string | undefined {
|
|
607
677
|
const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
|
|
608
678
|
if (typeof url !== 'string' || url === '') {
|
|
@@ -681,7 +751,60 @@ export function normalizeMessageContent(content: unknown): MessageContent {
|
|
|
681
751
|
return JSON.stringify(content);
|
|
682
752
|
}
|
|
683
753
|
|
|
684
|
-
|
|
754
|
+
/**
|
|
755
|
+
* Run any `file` or `file_url` content blocks through the plugin's file
|
|
756
|
+
* processor service. Custom plugins can register processors to fetch URLs,
|
|
757
|
+
* extract text, OCR, etc. Other block types are left untouched.
|
|
758
|
+
*
|
|
759
|
+
* The service is invoked repeatedly if a processor returns another file-like
|
|
760
|
+
* block (e.g. a `file_url` becomes a `file` block, which may then be converted
|
|
761
|
+
* to images by the PDF processor).
|
|
762
|
+
*/
|
|
763
|
+
async function processMessageContentFileBlocks(
|
|
764
|
+
content: unknown,
|
|
765
|
+
ctx: Context,
|
|
766
|
+
plugin: PluginAiApiServer,
|
|
767
|
+
): Promise<unknown> {
|
|
768
|
+
if (!Array.isArray(content)) return content;
|
|
769
|
+
const processed: unknown[] = [];
|
|
770
|
+
for (const block of content) {
|
|
771
|
+
processed.push(...(await processFileBlockChain(block, ctx, plugin, 0)));
|
|
772
|
+
}
|
|
773
|
+
return processed;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const MAX_FILE_PROCESSOR_CHAIN_DEPTH = 3;
|
|
777
|
+
|
|
778
|
+
async function processFileBlockChain(
|
|
779
|
+
block: unknown,
|
|
780
|
+
ctx: Context,
|
|
781
|
+
plugin: PluginAiApiServer,
|
|
782
|
+
depth: number,
|
|
783
|
+
): Promise<unknown[]> {
|
|
784
|
+
if (!isRecord(block) || (block.type !== 'file' && block.type !== 'file_url')) {
|
|
785
|
+
return [block];
|
|
786
|
+
}
|
|
787
|
+
if (depth > MAX_FILE_PROCESSOR_CHAIN_DEPTH) {
|
|
788
|
+
return [block];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const result = await plugin.fileProcessorService.process(block as FileContentBlock, { ctx });
|
|
792
|
+
const results = Array.isArray(result) ? result : [result];
|
|
793
|
+
|
|
794
|
+
const next: unknown[] = [];
|
|
795
|
+
for (const item of results) {
|
|
796
|
+
if (isRecord(item) && (item.type === 'file' || item.type === 'file_url')) {
|
|
797
|
+
// The output is still a file-like block; run it through the chain again
|
|
798
|
+
// so that a `file_url` -> `file` -> images pipeline can complete.
|
|
799
|
+
next.push(...(await processFileBlockChain(item, ctx, plugin, depth + 1)));
|
|
800
|
+
} else {
|
|
801
|
+
next.push(item);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return next;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'prompt', 'tools', 'tool_choice', 'stream', 'n']);
|
|
685
808
|
|
|
686
809
|
export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
|
|
687
810
|
return Object.fromEntries(
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
isStreamingRequested,
|
|
24
24
|
writeResponse,
|
|
25
25
|
} from '../utils/streaming';
|
|
26
|
+
import { getProviderRequestParameters, applyProviderRequestParameters } from './chat-completions';
|
|
26
27
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
27
28
|
import type PluginAiApiServer from '../plugin';
|
|
28
29
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
@@ -169,6 +170,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
169
170
|
|
|
170
171
|
const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
|
|
171
172
|
const chatModel = provider.createModel();
|
|
173
|
+
const providerRequestParameters = getProviderRequestParameters(body);
|
|
174
|
+
applyProviderRequestParameters(chatModel, providerRequestParameters);
|
|
172
175
|
markLlmProviderAttempted(ctx);
|
|
173
176
|
|
|
174
177
|
if (stream) {
|
|
@@ -179,9 +182,17 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
179
182
|
completionId,
|
|
180
183
|
body.model,
|
|
181
184
|
body.stream_options,
|
|
185
|
+
providerRequestParameters,
|
|
182
186
|
);
|
|
183
187
|
} else {
|
|
184
|
-
await handleNonStreamingTextCompletion(
|
|
188
|
+
await handleNonStreamingTextCompletion(
|
|
189
|
+
ctx,
|
|
190
|
+
chatModel,
|
|
191
|
+
langchainMessages,
|
|
192
|
+
completionId,
|
|
193
|
+
body.model,
|
|
194
|
+
providerRequestParameters,
|
|
195
|
+
);
|
|
185
196
|
}
|
|
186
197
|
} catch (err) {
|
|
187
198
|
ctx.log.error('AI API completions error:', err);
|
|
@@ -208,8 +219,9 @@ async function handleNonStreamingTextCompletion(
|
|
|
208
219
|
messages: [string, string][],
|
|
209
220
|
completionId: string,
|
|
210
221
|
modelName: string,
|
|
222
|
+
providerRequestParameters: Record<string, unknown>,
|
|
211
223
|
) {
|
|
212
|
-
const result = await chatModel.invoke(messages);
|
|
224
|
+
const result = await chatModel.invoke(messages, providerRequestParameters);
|
|
213
225
|
|
|
214
226
|
let text = '';
|
|
215
227
|
if (typeof result.content === 'string') {
|
|
@@ -219,10 +231,15 @@ async function handleNonStreamingTextCompletion(
|
|
|
219
231
|
text = textPart?.text || JSON.stringify(result.content);
|
|
220
232
|
}
|
|
221
233
|
|
|
222
|
-
const usage = setAiApiUsageResult(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
const usage = setAiApiUsageResult(
|
|
235
|
+
ctx,
|
|
236
|
+
result.usage_metadata,
|
|
237
|
+
{
|
|
238
|
+
gatewayResponseId: completionId,
|
|
239
|
+
providerRequestId: extractProviderRequestId(result),
|
|
240
|
+
},
|
|
241
|
+
result.response_metadata,
|
|
242
|
+
);
|
|
226
243
|
|
|
227
244
|
ctx.status = 200;
|
|
228
245
|
ctx.body = {
|
|
@@ -239,7 +256,14 @@ async function handleNonStreamingTextCompletion(
|
|
|
239
256
|
finish_reason: 'stop',
|
|
240
257
|
},
|
|
241
258
|
],
|
|
242
|
-
usage: usage
|
|
259
|
+
usage: usage
|
|
260
|
+
? {
|
|
261
|
+
prompt_tokens: usage.prompt_tokens,
|
|
262
|
+
completion_tokens: usage.completion_tokens,
|
|
263
|
+
total_tokens: usage.total_tokens,
|
|
264
|
+
prompt_tokens_details: { cached_tokens: usage.prompt_cache_tokens ?? null },
|
|
265
|
+
}
|
|
266
|
+
: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: null } },
|
|
243
267
|
};
|
|
244
268
|
}
|
|
245
269
|
|
|
@@ -252,6 +276,7 @@ async function handleStreamingTextCompletion(
|
|
|
252
276
|
completionId: string,
|
|
253
277
|
modelName: string,
|
|
254
278
|
streamOptions: Record<string, unknown> | undefined,
|
|
279
|
+
providerRequestParameters: Record<string, unknown> | undefined,
|
|
255
280
|
) {
|
|
256
281
|
ctx.set({
|
|
257
282
|
'Content-Type': 'text/event-stream',
|
|
@@ -266,6 +291,7 @@ async function handleStreamingTextCompletion(
|
|
|
266
291
|
let providerRequestId: string | undefined;
|
|
267
292
|
try {
|
|
268
293
|
const stream = await chatModel.stream(messages, {
|
|
294
|
+
...providerRequestParameters,
|
|
269
295
|
stream_options: { ...streamOptions, include_usage: true },
|
|
270
296
|
signal: requestAbort.signal,
|
|
271
297
|
});
|