plugin-ai-api 1.0.23 → 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/902.e74518750f1e4201.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
- package/dist/client-v2/902.c7c00a565085438a.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +4 -0
- package/dist/locale/vi-VN.json +4 -0
- package/dist/locale/zh-CN.json +4 -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/collections/ai-api-user-quota-policies.js +2 -1
- 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 +110 -19
- package/dist/server/routes/completions.js +59 -24
- package/dist/server/services/file-processor.js +262 -0
- package/dist/server/usage.js +33 -3
- package/dist/server/utils/direct-llm-context.js +319 -0
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/server/validation.js +3 -0
- package/dist/swagger.js +42 -3
- package/package.json +1 -1
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
- package/src/locale/en-US.json +4 -0
- package/src/locale/vi-VN.json +4 -0
- package/src/locale/zh-CN.json +4 -0
- package/src/server/__tests__/direct-llm-context.test.ts +206 -0
- 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 +173 -9
- package/src/server/__tests__/usage.test.ts +19 -0
- package/src/server/__tests__/validation.test.ts +36 -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/collections/ai-api-user-quota-policies.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 +157 -22
- package/src/server/routes/completions.ts +61 -23
- 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 +394 -0
- 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/server/validation.ts +3 -0
- package/src/swagger.ts +45 -3
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
- package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
|
@@ -31,7 +31,9 @@ import { enforceModelAccess } from '../utils/user-permissions';
|
|
|
31
31
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
32
32
|
import type PluginAiApiServer from '../plugin';
|
|
33
33
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
34
|
+
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
34
35
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
36
|
+
import { FileContentBlock, FileProcessorError } from '../services/file-processor';
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* POST /api/ai-llm/v1/chat/completions
|
|
@@ -142,8 +144,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
142
144
|
return;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
await prepareLlmBilling(ctx, resolved);
|
|
146
|
-
|
|
147
147
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
148
148
|
if (stream) {
|
|
149
149
|
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
@@ -166,13 +166,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
166
166
|
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
167
167
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
168
168
|
|
|
169
|
-
const Provider = providerMeta.provider;
|
|
170
|
-
const provider = new Provider({
|
|
171
|
-
app: ctx.app,
|
|
172
|
-
serviceOptions: service.options,
|
|
173
|
-
modelOptions,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
169
|
// ─── Build system prompt from AI Employee ───
|
|
177
170
|
let systemPrompt = '';
|
|
178
171
|
if (config?.defaultAiEmployee) {
|
|
@@ -197,12 +190,39 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
197
190
|
}
|
|
198
191
|
|
|
199
192
|
// ─── Build messages (inject system prompt if not provided by client) ───
|
|
200
|
-
|
|
193
|
+
let messages: OpenAIMessage[] = [...body.messages];
|
|
201
194
|
const hasSystemMessage = messages.some((m: any) => m.role === 'system');
|
|
202
195
|
if (systemPrompt && !hasSystemMessage) {
|
|
203
196
|
messages.unshift({ role: 'system', content: systemPrompt });
|
|
204
197
|
}
|
|
205
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
|
+
|
|
207
|
+
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
208
|
+
serviceName: service.name,
|
|
209
|
+
modelId,
|
|
210
|
+
messages,
|
|
211
|
+
tools: body.tools,
|
|
212
|
+
maxCompletionTokens: body.max_completion_tokens,
|
|
213
|
+
maxTokens: body.max_tokens,
|
|
214
|
+
});
|
|
215
|
+
messages = preparedContext.messages;
|
|
216
|
+
|
|
217
|
+
await prepareLlmBilling(ctx, resolved);
|
|
218
|
+
|
|
219
|
+
const Provider = providerMeta.provider;
|
|
220
|
+
const provider = new Provider({
|
|
221
|
+
app: ctx.app,
|
|
222
|
+
serviceOptions: service.options,
|
|
223
|
+
modelOptions,
|
|
224
|
+
});
|
|
225
|
+
|
|
206
226
|
// ─── Build message tuples for LangChain model ───
|
|
207
227
|
// LangChain chat models accept [role, content] tuples or BaseMessage objects.
|
|
208
228
|
// We use tuples to avoid importing @langchain/core directly.
|
|
@@ -257,15 +277,17 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
257
277
|
}
|
|
258
278
|
} catch (err) {
|
|
259
279
|
ctx.log.error('AI API chat completions error:', err);
|
|
260
|
-
if (!ctx.res
|
|
280
|
+
if (!ctx.res?.headersSent) {
|
|
261
281
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
262
|
-
|
|
282
|
+
const isContextError = err instanceof DirectLlmContextError;
|
|
283
|
+
const isFileError = err instanceof FileProcessorError;
|
|
284
|
+
ctx.status = isQuotaError ? 429 : isContextError || isFileError ? 400 : 500;
|
|
263
285
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
264
286
|
ctx.body = toOpenAIError(
|
|
265
287
|
ctx.status,
|
|
266
288
|
getErrorMessage(err, 'Internal server error'),
|
|
267
|
-
isQuotaError ? 'quota_error' : 'server_error',
|
|
268
|
-
isQuotaError ? err.code : undefined,
|
|
289
|
+
isQuotaError ? 'quota_error' : isContextError || isFileError ? 'invalid_request_error' : 'server_error',
|
|
290
|
+
isQuotaError || isContextError || isFileError ? err.code : undefined,
|
|
269
291
|
);
|
|
270
292
|
}
|
|
271
293
|
}
|
|
@@ -293,10 +315,15 @@ async function handleNonStreamingCompletion(
|
|
|
293
315
|
}
|
|
294
316
|
|
|
295
317
|
// Extract usage if available
|
|
296
|
-
const usage = setAiApiUsageResult(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
318
|
+
const usage = setAiApiUsageResult(
|
|
319
|
+
ctx,
|
|
320
|
+
result.usage_metadata,
|
|
321
|
+
{
|
|
322
|
+
gatewayResponseId: completionId,
|
|
323
|
+
providerRequestId: extractProviderRequestId(result),
|
|
324
|
+
},
|
|
325
|
+
result.response_metadata,
|
|
326
|
+
);
|
|
300
327
|
|
|
301
328
|
ctx.status = 200;
|
|
302
329
|
const toolCalls = normalizeToolCalls(result.tool_calls);
|
|
@@ -463,7 +490,7 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
463
490
|
* the model answers as if the attachment was never sent. A 400 is far easier to
|
|
464
491
|
* debug than a confidently wrong completion.
|
|
465
492
|
*/
|
|
466
|
-
const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
|
|
493
|
+
const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url', 'file', 'file_url']);
|
|
467
494
|
|
|
468
495
|
/**
|
|
469
496
|
* Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
|
|
@@ -474,6 +501,14 @@ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
|
|
|
474
501
|
*/
|
|
475
502
|
const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
476
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
|
+
|
|
477
512
|
/**
|
|
478
513
|
* The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
|
|
479
514
|
* `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
|
|
@@ -579,8 +614,8 @@ function describeContentBlockProblem(block: unknown): string | undefined {
|
|
|
579
614
|
if (!type) return "each content block requires a 'type' field";
|
|
580
615
|
if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
|
|
581
616
|
return (
|
|
582
|
-
`content block type '${type}' is not supported — this gateway forwards 'text'
|
|
583
|
-
`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`
|
|
584
619
|
);
|
|
585
620
|
}
|
|
586
621
|
|
|
@@ -588,9 +623,56 @@ function describeContentBlockProblem(block: unknown): string | undefined {
|
|
|
588
623
|
return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
|
|
589
624
|
}
|
|
590
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
|
+
|
|
591
634
|
return describeImageUrlProblem(block.image_url);
|
|
592
635
|
}
|
|
593
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
|
+
|
|
594
676
|
function describeImageUrlProblem(imageUrl: unknown): string | undefined {
|
|
595
677
|
const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
|
|
596
678
|
if (typeof url !== 'string' || url === '') {
|
|
@@ -669,7 +751,60 @@ export function normalizeMessageContent(content: unknown): MessageContent {
|
|
|
669
751
|
return JSON.stringify(content);
|
|
670
752
|
}
|
|
671
753
|
|
|
672
|
-
|
|
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']);
|
|
673
808
|
|
|
674
809
|
export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
|
|
675
810
|
return Object.fromEntries(
|
|
@@ -23,9 +23,11 @@ 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';
|
|
30
|
+
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
29
31
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -114,8 +116,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
await prepareLlmBilling(ctx, resolved);
|
|
118
|
-
|
|
119
119
|
const modelOptions: Record<string, any> = {
|
|
120
120
|
model: modelId,
|
|
121
121
|
llmService: service.name,
|
|
@@ -126,13 +126,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
126
126
|
if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
|
|
127
127
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
128
128
|
|
|
129
|
-
const Provider = providerMeta.provider;
|
|
130
|
-
const provider = new Provider({
|
|
131
|
-
app: ctx.app,
|
|
132
|
-
serviceOptions: service.options,
|
|
133
|
-
modelOptions,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
129
|
// ─── Convert prompt to message tuple ───
|
|
137
130
|
const prompt =
|
|
138
131
|
typeof body.prompt === 'string'
|
|
@@ -142,7 +135,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
142
135
|
: String(body.prompt);
|
|
143
136
|
|
|
144
137
|
// Inject system prompt from AI Employee if configured
|
|
145
|
-
const
|
|
138
|
+
const messages: OpenAIMessage[] = [];
|
|
146
139
|
if (config?.defaultAiEmployee) {
|
|
147
140
|
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
148
141
|
filter: { username: config.defaultAiEmployee },
|
|
@@ -150,14 +143,35 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
150
143
|
if (employee) {
|
|
151
144
|
const systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
152
145
|
if (systemPrompt) {
|
|
153
|
-
|
|
146
|
+
messages.push({ role: 'system', content: systemPrompt });
|
|
154
147
|
}
|
|
155
148
|
}
|
|
156
149
|
}
|
|
157
|
-
|
|
150
|
+
messages.push({ role: 'user', content: prompt });
|
|
151
|
+
|
|
152
|
+
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
153
|
+
serviceName: service.name,
|
|
154
|
+
modelId,
|
|
155
|
+
messages,
|
|
156
|
+
maxTokens: body.max_tokens,
|
|
157
|
+
});
|
|
158
|
+
await prepareLlmBilling(ctx, resolved);
|
|
159
|
+
|
|
160
|
+
const Provider = providerMeta.provider;
|
|
161
|
+
const provider = new Provider({
|
|
162
|
+
app: ctx.app,
|
|
163
|
+
serviceOptions: service.options,
|
|
164
|
+
modelOptions,
|
|
165
|
+
});
|
|
166
|
+
const langchainMessages = preparedContext.messages.map((message): [string, string] => [
|
|
167
|
+
message.role === 'user' ? 'human' : message.role,
|
|
168
|
+
String(message.content ?? ''),
|
|
169
|
+
]);
|
|
158
170
|
|
|
159
171
|
const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
|
|
160
172
|
const chatModel = provider.createModel();
|
|
173
|
+
const providerRequestParameters = getProviderRequestParameters(body);
|
|
174
|
+
applyProviderRequestParameters(chatModel, providerRequestParameters);
|
|
161
175
|
markLlmProviderAttempted(ctx);
|
|
162
176
|
|
|
163
177
|
if (stream) {
|
|
@@ -168,21 +182,30 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
168
182
|
completionId,
|
|
169
183
|
body.model,
|
|
170
184
|
body.stream_options,
|
|
185
|
+
providerRequestParameters,
|
|
171
186
|
);
|
|
172
187
|
} else {
|
|
173
|
-
await handleNonStreamingTextCompletion(
|
|
188
|
+
await handleNonStreamingTextCompletion(
|
|
189
|
+
ctx,
|
|
190
|
+
chatModel,
|
|
191
|
+
langchainMessages,
|
|
192
|
+
completionId,
|
|
193
|
+
body.model,
|
|
194
|
+
providerRequestParameters,
|
|
195
|
+
);
|
|
174
196
|
}
|
|
175
197
|
} catch (err) {
|
|
176
198
|
ctx.log.error('AI API completions error:', err);
|
|
177
|
-
if (!ctx.res
|
|
199
|
+
if (!ctx.res?.headersSent) {
|
|
178
200
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
179
|
-
|
|
201
|
+
const isContextError = err instanceof DirectLlmContextError;
|
|
202
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
180
203
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
181
204
|
ctx.body = toOpenAIError(
|
|
182
205
|
ctx.status,
|
|
183
206
|
getErrorMessage(err, 'Internal server error'),
|
|
184
|
-
isQuotaError ? 'quota_error' : 'server_error',
|
|
185
|
-
isQuotaError ? err.code : undefined,
|
|
207
|
+
isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
|
|
208
|
+
isQuotaError || isContextError ? err.code : undefined,
|
|
186
209
|
);
|
|
187
210
|
}
|
|
188
211
|
}
|
|
@@ -196,8 +219,9 @@ async function handleNonStreamingTextCompletion(
|
|
|
196
219
|
messages: [string, string][],
|
|
197
220
|
completionId: string,
|
|
198
221
|
modelName: string,
|
|
222
|
+
providerRequestParameters: Record<string, unknown>,
|
|
199
223
|
) {
|
|
200
|
-
const result = await chatModel.invoke(messages);
|
|
224
|
+
const result = await chatModel.invoke(messages, providerRequestParameters);
|
|
201
225
|
|
|
202
226
|
let text = '';
|
|
203
227
|
if (typeof result.content === 'string') {
|
|
@@ -207,10 +231,15 @@ async function handleNonStreamingTextCompletion(
|
|
|
207
231
|
text = textPart?.text || JSON.stringify(result.content);
|
|
208
232
|
}
|
|
209
233
|
|
|
210
|
-
const usage = setAiApiUsageResult(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
234
|
+
const usage = setAiApiUsageResult(
|
|
235
|
+
ctx,
|
|
236
|
+
result.usage_metadata,
|
|
237
|
+
{
|
|
238
|
+
gatewayResponseId: completionId,
|
|
239
|
+
providerRequestId: extractProviderRequestId(result),
|
|
240
|
+
},
|
|
241
|
+
result.response_metadata,
|
|
242
|
+
);
|
|
214
243
|
|
|
215
244
|
ctx.status = 200;
|
|
216
245
|
ctx.body = {
|
|
@@ -227,7 +256,14 @@ async function handleNonStreamingTextCompletion(
|
|
|
227
256
|
finish_reason: 'stop',
|
|
228
257
|
},
|
|
229
258
|
],
|
|
230
|
-
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 } },
|
|
231
267
|
};
|
|
232
268
|
}
|
|
233
269
|
|
|
@@ -240,6 +276,7 @@ async function handleStreamingTextCompletion(
|
|
|
240
276
|
completionId: string,
|
|
241
277
|
modelName: string,
|
|
242
278
|
streamOptions: Record<string, unknown> | undefined,
|
|
279
|
+
providerRequestParameters: Record<string, unknown> | undefined,
|
|
243
280
|
) {
|
|
244
281
|
ctx.set({
|
|
245
282
|
'Content-Type': 'text/event-stream',
|
|
@@ -254,6 +291,7 @@ async function handleStreamingTextCompletion(
|
|
|
254
291
|
let providerRequestId: string | undefined;
|
|
255
292
|
try {
|
|
256
293
|
const stream = await chatModel.stream(messages, {
|
|
294
|
+
...providerRequestParameters,
|
|
257
295
|
stream_options: { ...streamOptions, include_usage: true },
|
|
258
296
|
signal: requestAbort.signal,
|
|
259
297
|
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
FileProcessorService,
|
|
4
|
+
base64FileForwarder,
|
|
5
|
+
httpFileUrlFetcher,
|
|
6
|
+
fetchFileAsBase64,
|
|
7
|
+
pdfFileProcessor,
|
|
8
|
+
type PdfToImageRenderer,
|
|
9
|
+
} from '../file-processor';
|
|
10
|
+
|
|
11
|
+
describe('FileProcessorService', () => {
|
|
12
|
+
it('forwards file blocks with base64 data unchanged', async () => {
|
|
13
|
+
const service = new FileProcessorService();
|
|
14
|
+
service.register(base64FileForwarder);
|
|
15
|
+
|
|
16
|
+
const block = { type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } };
|
|
17
|
+
const result = await service.process(block, { ctx: {} as any });
|
|
18
|
+
|
|
19
|
+
expect(result).toEqual(block);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns the block unchanged when no processor can handle it', async () => {
|
|
23
|
+
const service = new FileProcessorService();
|
|
24
|
+
const block = { type: 'text', text: 'hello' };
|
|
25
|
+
const result = await service.process(block, { ctx: {} as any });
|
|
26
|
+
|
|
27
|
+
expect(result).toEqual(block);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('allows custom processors to override default behavior', async () => {
|
|
31
|
+
const service = new FileProcessorService();
|
|
32
|
+
service.register(base64FileForwarder);
|
|
33
|
+
|
|
34
|
+
const customProcessor = {
|
|
35
|
+
name: 'customFileProcessor',
|
|
36
|
+
canHandle: (block: { type: string }) => block.type === 'file',
|
|
37
|
+
process: vi.fn().mockResolvedValue({ type: 'file', file: { file_data: 'data:text/plain;base64,SGVsbG8=' } }),
|
|
38
|
+
};
|
|
39
|
+
service.register(customProcessor);
|
|
40
|
+
|
|
41
|
+
const block = { type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } };
|
|
42
|
+
const result = await service.process(block, { ctx: {} as any });
|
|
43
|
+
|
|
44
|
+
expect(customProcessor.process).toHaveBeenCalledWith(block, { ctx: {} as any });
|
|
45
|
+
expect(result).toEqual({ type: 'file', file: { file_data: 'data:text/plain;base64,SGVsbG8=' } });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('unregister removes a processor by name', () => {
|
|
49
|
+
const service = new FileProcessorService();
|
|
50
|
+
service.register(base64FileForwarder);
|
|
51
|
+
expect(service.list()).toHaveLength(1);
|
|
52
|
+
|
|
53
|
+
service.unregister(base64FileForwarder.name);
|
|
54
|
+
expect(service.list()).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('httpFileUrlFetcher', () => {
|
|
59
|
+
it('fetches a file from an http(s) URL and converts it to a file block', async () => {
|
|
60
|
+
const originalFetch = globalThis.fetch;
|
|
61
|
+
const fileBuffer = Buffer.from('hello world');
|
|
62
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: new Headers({
|
|
66
|
+
'content-type': 'text/plain',
|
|
67
|
+
'content-length': String(fileBuffer.length),
|
|
68
|
+
}),
|
|
69
|
+
arrayBuffer: () =>
|
|
70
|
+
Promise.resolve(fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.length)),
|
|
71
|
+
} as unknown as Response);
|
|
72
|
+
|
|
73
|
+
const result = await httpFileUrlFetcher.process(
|
|
74
|
+
{ type: 'file_url', file_url: { url: 'https://example.com/hello.txt' } },
|
|
75
|
+
{ ctx: {} as any },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(result).toMatchObject({
|
|
79
|
+
type: 'file',
|
|
80
|
+
file: {
|
|
81
|
+
file_data: expect.stringContaining('data:text/plain;base64,'),
|
|
82
|
+
mime_type: 'text/plain',
|
|
83
|
+
filename: 'hello.txt',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
globalThis.fetch = originalFetch;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects unsupported protocols', async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
httpFileUrlFetcher.process(
|
|
93
|
+
{ type: 'file_url', file_url: { url: 'ftp://example.com/file.txt' } },
|
|
94
|
+
{ ctx: {} as any },
|
|
95
|
+
),
|
|
96
|
+
).rejects.toThrow('protocol');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('fetchFileAsBase64', () => {
|
|
101
|
+
it('validates URL protocols', async () => {
|
|
102
|
+
await expect(fetchFileAsBase64('ftp://example.com/file.txt')).rejects.toThrow('protocol');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects malformed URLs', async () => {
|
|
106
|
+
await expect(fetchFileAsBase64('not a url')).rejects.toThrow('valid URL');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('pdfFileProcessor', () => {
|
|
111
|
+
const pdfBuffer = Buffer.from('%PDF-1.4\n1 0 obj\n<<\n>>\nendobj\n', 'binary');
|
|
112
|
+
const pdfDataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`;
|
|
113
|
+
|
|
114
|
+
function createContext(config: { pdfRenderPagesAsImages?: boolean }, renderer?: PdfToImageRenderer | null) {
|
|
115
|
+
const service = new FileProcessorService();
|
|
116
|
+
if (renderer) {
|
|
117
|
+
service.registerPdfRenderer(renderer);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ctx: {
|
|
121
|
+
db: {
|
|
122
|
+
getRepository: vi.fn((name: string) => {
|
|
123
|
+
if (name === 'aiApiConfig') {
|
|
124
|
+
return {
|
|
125
|
+
findOne: vi.fn().mockResolvedValue({
|
|
126
|
+
get: (key: string) => (key === 'pdfRenderPagesAsImages' ? config.pdfRenderPagesAsImages : undefined),
|
|
127
|
+
pdfRenderPagesAsImages: config.pdfRenderPagesAsImages,
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { findOne: vi.fn() };
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
app: {
|
|
135
|
+
pm: {
|
|
136
|
+
get: vi.fn().mockReturnValue({
|
|
137
|
+
fileProcessorService: service,
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
log: { warn: vi.fn() },
|
|
142
|
+
},
|
|
143
|
+
} as any;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it('forwards a PDF file block when pdfRenderPagesAsImages is disabled', async () => {
|
|
147
|
+
const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
|
|
148
|
+
const result = await pdfFileProcessor.process(block, createContext({ pdfRenderPagesAsImages: false }));
|
|
149
|
+
expect(result).toBe(block);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('forwards a PDF and warns when rendering is enabled but no renderer is registered', async () => {
|
|
153
|
+
const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
|
|
154
|
+
const context = createContext({ pdfRenderPagesAsImages: true });
|
|
155
|
+
const result = await pdfFileProcessor.process(block, context);
|
|
156
|
+
|
|
157
|
+
expect(result).toBe(block);
|
|
158
|
+
expect(context.ctx.log.warn).toHaveBeenCalledWith(
|
|
159
|
+
expect.stringContaining('pdfRenderPagesAsImages is enabled but no PdfToImageRenderer is registered'),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('converts a PDF file block into image_url blocks when a renderer is registered', async () => {
|
|
164
|
+
const renderer: PdfToImageRenderer = {
|
|
165
|
+
name: 'mockRenderer',
|
|
166
|
+
render: vi.fn().mockResolvedValue([Buffer.from('page1'), Buffer.from('page2')]),
|
|
167
|
+
};
|
|
168
|
+
const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
|
|
169
|
+
const result = await pdfFileProcessor.process(block, createContext({ pdfRenderPagesAsImages: true }, renderer));
|
|
170
|
+
|
|
171
|
+
expect(Array.isArray(result)).toBe(true);
|
|
172
|
+
expect(result).toHaveLength(2);
|
|
173
|
+
expect((result as any[])[0]).toMatchObject({ type: 'image_url' });
|
|
174
|
+
expect((result as any[])[0].image_url.url).toMatch(/^data:image\/png;base64,/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('does not convert non-PDF file blocks', async () => {
|
|
178
|
+
const block = {
|
|
179
|
+
type: 'file',
|
|
180
|
+
file: { file_data: 'data:text/plain;base64,SGVsbG8=', filename: 'hello.txt' },
|
|
181
|
+
} as any;
|
|
182
|
+
expect(pdfFileProcessor.canHandle(block)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|