plugin-ai-api 1.0.9 → 1.0.11

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.
@@ -1,299 +1,322 @@
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 { generateCompletionId, toOpenAIError, formatSSE, formatSSEDone } from '../utils/openai-format';
12
- import { resolveModelString } from '../utils/resolve-service';
13
- import type PluginAiApiServer from '../plugin';
14
-
15
- /**
16
- * POST /api/ai-llm/v1/completions
17
- *
18
- * Handles legacy OpenAI text completions API.
19
- * Converts prompt to messages and delegates to the chat model.
20
- * Required by LiteLLM and other tools that test via legacy completions endpoint.
21
- */
22
- export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer) {
23
- const body = ctx.request.body as any;
24
-
25
- // ─── Validate request ───
26
- if (!body?.model) {
27
- ctx.status = 400;
28
- ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
29
- return;
30
- }
31
-
32
- if (!body?.prompt && body.prompt !== '') {
33
- ctx.status = 400;
34
- ctx.body = toOpenAIError(400, "'prompt' is required", 'invalid_request_error', 'missing_prompt');
35
- return;
36
- }
37
-
38
- // ─── Reject unsupported n parameter ───
39
- if (body.n !== undefined && body.n !== null && body.n !== 1) {
40
- ctx.status = 400;
41
- ctx.body = toOpenAIError(
42
- 400,
43
- `The 'n' parameter value ${body.n} is not supported. ` +
44
- `This API gateway always returns exactly one completion (n=1). Please omit 'n' or set it to 1.`,
45
- 'invalid_request_error',
46
- 'unsupported_parameter',
47
- );
48
- return;
49
- }
50
-
51
- const stream = body.stream === true;
52
-
53
- // ─── Resolve model string against DB ───
54
- const resolved = await resolveModelString(ctx, body.model);
55
- if (!resolved) {
56
- ctx.status = 404;
57
- ctx.body = toOpenAIError(
58
- 404,
59
- `Could not resolve model '${body.model}'. Format: 'serviceName/modelId'. Use GET /v1/models to see available models.`,
60
- 'invalid_request_error',
61
- 'model_not_found',
62
- );
63
- return;
64
- }
65
-
66
- const { service, modelId } = resolved;
67
-
68
- try {
69
- const aiPlugin = ctx.app.pm.get('ai') as any;
70
- if (!aiPlugin) {
71
- ctx.status = 500;
72
- ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
73
- return;
74
- }
75
-
76
- if (service.enabled === false) {
77
- ctx.status = 404;
78
- ctx.body = toOpenAIError(
79
- 404,
80
- `LLM service '${service.title || service.name}' is disabled`,
81
- 'invalid_request_error',
82
- 'model_not_found',
83
- );
84
- return;
85
- }
86
-
87
- // ─── Check whitelist ───
88
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
89
- if (config?.enabledLlmServices?.length) {
90
- const serviceName = service.name;
91
- const serviceTitle = service.title;
92
- const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
93
- if (!isAllowed) {
94
- ctx.status = 403;
95
- ctx.body = toOpenAIError(
96
- 403,
97
- `LLM service '${service.title || service.name}' is not enabled for API access`,
98
- 'invalid_request_error',
99
- 'model_not_available',
100
- );
101
- return;
102
- }
103
- }
104
-
105
- // ─── Create LLM provider instance ───
106
- const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
107
- if (!providerMeta) {
108
- ctx.status = 500;
109
- ctx.body = toOpenAIError(500, `Provider '${service.provider}' not registered`, 'server_error');
110
- return;
111
- }
112
-
113
- const modelOptions: Record<string, any> = {
114
- model: modelId,
115
- llmService: service.name,
116
- };
117
-
118
- if (body.temperature !== undefined) modelOptions.temperature = body.temperature;
119
- if (body.top_p !== undefined) modelOptions.topP = body.top_p;
120
- if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
121
- if (body.stop !== undefined) modelOptions.stop = body.stop;
122
-
123
- const Provider = providerMeta.provider;
124
- const provider = new Provider({
125
- app: ctx.app,
126
- serviceOptions: service.options,
127
- modelOptions,
128
- });
129
-
130
- // ─── Convert prompt to message tuple ───
131
- const prompt =
132
- typeof body.prompt === 'string'
133
- ? body.prompt
134
- : Array.isArray(body.prompt)
135
- ? body.prompt.join('\n')
136
- : String(body.prompt);
137
-
138
- // Inject system prompt from AI Employee if configured
139
- const langchainMessages: [string, string][] = [];
140
- if (config?.defaultAiEmployee) {
141
- const employee = await ctx.db.getRepository('aiEmployees').findOne({
142
- filter: { username: config.defaultAiEmployee },
143
- });
144
- if (employee) {
145
- const systemPrompt = employee.about || employee.defaultPrompt || '';
146
- if (systemPrompt) {
147
- langchainMessages.push(['system', systemPrompt]);
148
- }
149
- }
150
- }
151
- langchainMessages.push(['human', prompt]);
152
-
153
- const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
154
- const chatModel = provider.createModel();
155
-
156
- if (stream) {
157
- await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
158
- } else {
159
- await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
160
- }
161
- } catch (err) {
162
- ctx.log.error('AI API completions error:', err);
163
- if (!ctx.res.headersSent) {
164
- ctx.status = 500;
165
- ctx.body = toOpenAIError(500, err.message || 'Internal server error', 'server_error');
166
- }
167
- }
168
- }
169
-
170
- // ─── Non-streaming text completion ───
171
-
172
- async function handleNonStreamingTextCompletion(
173
- ctx: Context,
174
- chatModel: any,
175
- messages: [string, string][],
176
- completionId: string,
177
- modelName: string,
178
- ) {
179
- const result = await chatModel.invoke(messages);
180
-
181
- let text = '';
182
- if (typeof result.content === 'string') {
183
- text = result.content;
184
- } else if (Array.isArray(result.content)) {
185
- const textPart = result.content.find((c: any) => c.type === 'text');
186
- text = textPart?.text || JSON.stringify(result.content);
187
- }
188
-
189
- const usage = result.usage_metadata
190
- ? {
191
- prompt_tokens: result.usage_metadata.input_tokens || 0,
192
- completion_tokens: result.usage_metadata.output_tokens || 0,
193
- total_tokens: result.usage_metadata.total_tokens || 0,
194
- }
195
- : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
196
-
197
- ctx.status = 200;
198
- ctx.body = {
199
- id: completionId,
200
- object: 'text_completion',
201
- created: Math.floor(Date.now() / 1000),
202
- model: modelName,
203
- system_fingerprint: null,
204
- choices: [
205
- {
206
- text,
207
- index: 0,
208
- logprobs: null,
209
- finish_reason: 'stop',
210
- },
211
- ],
212
- usage,
213
- };
214
- }
215
-
216
- // ─── Streaming text completion ───
217
-
218
- async function handleStreamingTextCompletion(
219
- ctx: Context,
220
- chatModel: any,
221
- messages: [string, string][],
222
- completionId: string,
223
- modelName: string,
224
- ) {
225
- ctx.set({
226
- 'Content-Type': 'text/event-stream',
227
- 'Cache-Control': 'no-cache',
228
- Connection: 'keep-alive',
229
- 'X-Accel-Buffering': 'no',
230
- });
231
- ctx.status = 200;
232
-
233
- try {
234
- const stream = await chatModel.stream(messages);
235
-
236
- for await (const chunk of stream) {
237
- let text = '';
238
- if (typeof chunk.content === 'string') {
239
- text = chunk.content;
240
- } else if (Array.isArray(chunk.content)) {
241
- const textPart = chunk.content.find((c: any) => c.type === 'text');
242
- text = textPart?.text || '';
243
- }
244
-
245
- if (text) {
246
- ctx.res.write(
247
- formatSSE({
248
- id: completionId,
249
- object: 'text_completion',
250
- created: Math.floor(Date.now() / 1000),
251
- model: modelName,
252
- system_fingerprint: null,
253
- choices: [
254
- {
255
- text,
256
- index: 0,
257
- logprobs: null,
258
- finish_reason: null,
259
- },
260
- ],
261
- }),
262
- );
263
- }
264
- }
265
-
266
- // Final chunk
267
- ctx.res.write(
268
- formatSSE({
269
- id: completionId,
270
- object: 'text_completion',
271
- created: Math.floor(Date.now() / 1000),
272
- model: modelName,
273
- system_fingerprint: null,
274
- choices: [
275
- {
276
- text: '',
277
- index: 0,
278
- logprobs: null,
279
- finish_reason: 'stop',
280
- },
281
- ],
282
- }),
283
- );
284
-
285
- ctx.res.write(formatSSEDone());
286
- } catch (err) {
287
- ctx.log.error('AI API completions streaming error:', err);
288
- ctx.res.write(
289
- formatSSE({
290
- error: {
291
- message: err.message || 'Streaming error',
292
- type: 'server_error',
293
- },
294
- }),
295
- );
296
- } finally {
297
- ctx.res.end();
298
- }
299
- }
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 { generateCompletionId, toOpenAIError, formatSSE, formatSSEDone } from '../utils/openai-format';
12
+ import { resolveModelString } from '../utils/resolve-service';
13
+ import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
14
+ import type PluginAiApiServer from '../plugin';
15
+
16
+ /**
17
+ * POST /api/ai-llm/v1/completions
18
+ *
19
+ * Handles legacy OpenAI text completions API.
20
+ * Converts prompt to messages and delegates to the chat model.
21
+ * Required by LiteLLM and other tools that test via legacy completions endpoint.
22
+ */
23
+ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer) {
24
+ const body = ctx.request.body as any;
25
+
26
+ // ─── Validate request ───
27
+ if (!body?.model) {
28
+ ctx.status = 400;
29
+ ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
30
+ return;
31
+ }
32
+
33
+ if (!body?.prompt && body.prompt !== '') {
34
+ ctx.status = 400;
35
+ ctx.body = toOpenAIError(400, "'prompt' is required", 'invalid_request_error', 'missing_prompt');
36
+ return;
37
+ }
38
+
39
+ // ─── Reject unsupported n parameter ───
40
+ if (body.n !== undefined && body.n !== null && body.n !== 1) {
41
+ ctx.status = 400;
42
+ ctx.body = toOpenAIError(
43
+ 400,
44
+ `The 'n' parameter value ${body.n} is not supported. ` +
45
+ `This API gateway always returns exactly one completion (n=1). Please omit 'n' or set it to 1.`,
46
+ 'invalid_request_error',
47
+ 'unsupported_parameter',
48
+ );
49
+ return;
50
+ }
51
+
52
+ const stream = isStreamingRequested(body.stream);
53
+
54
+ // ─── Resolve model string against DB ───
55
+ const resolved = await resolveModelString(ctx, body.model);
56
+ if (!resolved) {
57
+ ctx.status = 404;
58
+ ctx.body = toOpenAIError(
59
+ 404,
60
+ `Could not resolve model '${body.model}'. Format: 'serviceName/modelId'. Use GET /v1/models to see available models.`,
61
+ 'invalid_request_error',
62
+ 'model_not_found',
63
+ );
64
+ return;
65
+ }
66
+
67
+ const { service, modelId } = resolved;
68
+
69
+ try {
70
+ const aiPlugin = ctx.app.pm.get('ai') as any;
71
+ if (!aiPlugin) {
72
+ ctx.status = 500;
73
+ ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
74
+ return;
75
+ }
76
+
77
+ if (service.enabled === false) {
78
+ ctx.status = 404;
79
+ ctx.body = toOpenAIError(
80
+ 404,
81
+ `LLM service '${service.title || service.name}' is disabled`,
82
+ 'invalid_request_error',
83
+ 'model_not_found',
84
+ );
85
+ return;
86
+ }
87
+
88
+ // ─── Check whitelist ───
89
+ const config = await ctx.db.getRepository('aiApiConfig').findOne();
90
+ if (config?.enabledLlmServices?.length) {
91
+ const serviceName = service.name;
92
+ const serviceTitle = service.title;
93
+ const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
94
+ if (!isAllowed) {
95
+ ctx.status = 403;
96
+ ctx.body = toOpenAIError(
97
+ 403,
98
+ `LLM service '${service.title || service.name}' is not enabled for API access`,
99
+ 'invalid_request_error',
100
+ 'model_not_available',
101
+ );
102
+ return;
103
+ }
104
+ }
105
+
106
+ // ─── Create LLM provider instance ───
107
+ const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
108
+ if (!providerMeta) {
109
+ ctx.status = 500;
110
+ ctx.body = toOpenAIError(500, `Provider '${service.provider}' not registered`, 'server_error');
111
+ return;
112
+ }
113
+
114
+ const modelOptions: Record<string, any> = {
115
+ model: modelId,
116
+ llmService: service.name,
117
+ };
118
+
119
+ if (body.temperature !== undefined) modelOptions.temperature = body.temperature;
120
+ if (body.top_p !== undefined) modelOptions.topP = body.top_p;
121
+ if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
122
+ if (body.stop !== undefined) modelOptions.stop = body.stop;
123
+
124
+ const Provider = providerMeta.provider;
125
+ const provider = new Provider({
126
+ app: ctx.app,
127
+ serviceOptions: service.options,
128
+ modelOptions,
129
+ });
130
+
131
+ // ─── Convert prompt to message tuple ───
132
+ const prompt =
133
+ typeof body.prompt === 'string'
134
+ ? body.prompt
135
+ : Array.isArray(body.prompt)
136
+ ? body.prompt.join('\n')
137
+ : String(body.prompt);
138
+
139
+ // Inject system prompt from AI Employee if configured
140
+ const langchainMessages: [string, string][] = [];
141
+ if (config?.defaultAiEmployee) {
142
+ const employee = await ctx.db.getRepository('aiEmployees').findOne({
143
+ filter: { username: config.defaultAiEmployee },
144
+ });
145
+ if (employee) {
146
+ const systemPrompt = employee.about || employee.defaultPrompt || '';
147
+ if (systemPrompt) {
148
+ langchainMessages.push(['system', systemPrompt]);
149
+ }
150
+ }
151
+ }
152
+ langchainMessages.push(['human', prompt]);
153
+
154
+ const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
155
+ const chatModel = provider.createModel();
156
+
157
+ if (stream) {
158
+ await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
159
+ } else {
160
+ await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
161
+ }
162
+ } catch (err) {
163
+ ctx.log.error('AI API completions error:', err);
164
+ if (!ctx.res.headersSent) {
165
+ ctx.status = 500;
166
+ ctx.body = toOpenAIError(500, getErrorMessage(err, 'Internal server error'), 'server_error');
167
+ }
168
+ }
169
+ }
170
+
171
+ // ─── Non-streaming text completion ───
172
+
173
+ async function handleNonStreamingTextCompletion(
174
+ ctx: Context,
175
+ chatModel: any,
176
+ messages: [string, string][],
177
+ completionId: string,
178
+ modelName: string,
179
+ ) {
180
+ const result = await chatModel.invoke(messages);
181
+
182
+ let text = '';
183
+ if (typeof result.content === 'string') {
184
+ text = result.content;
185
+ } else if (Array.isArray(result.content)) {
186
+ const textPart = result.content.find((c: any) => c.type === 'text');
187
+ text = textPart?.text || JSON.stringify(result.content);
188
+ }
189
+
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 };
197
+
198
+ ctx.status = 200;
199
+ ctx.body = {
200
+ id: completionId,
201
+ object: 'text_completion',
202
+ created: Math.floor(Date.now() / 1000),
203
+ model: modelName,
204
+ system_fingerprint: null,
205
+ choices: [
206
+ {
207
+ text,
208
+ index: 0,
209
+ logprobs: null,
210
+ finish_reason: 'stop',
211
+ },
212
+ ],
213
+ usage,
214
+ };
215
+ }
216
+
217
+ // ─── Streaming text completion ───
218
+
219
+ async function handleStreamingTextCompletion(
220
+ ctx: Context,
221
+ chatModel: any,
222
+ messages: [string, string][],
223
+ completionId: string,
224
+ modelName: string,
225
+ ) {
226
+ ctx.set({
227
+ 'Content-Type': 'text/event-stream',
228
+ 'Cache-Control': 'no-cache',
229
+ Connection: 'keep-alive',
230
+ 'X-Accel-Buffering': 'no',
231
+ });
232
+ ctx.status = 200;
233
+
234
+ const requestAbort = createRequestAbortController(ctx);
235
+ let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined;
236
+ try {
237
+ const stream = await chatModel.stream(messages, { signal: requestAbort.signal });
238
+
239
+ for await (const chunk of stream) {
240
+ if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
241
+ let text = '';
242
+ if (typeof chunk.content === 'string') {
243
+ text = chunk.content;
244
+ } else if (Array.isArray(chunk.content)) {
245
+ const textPart = chunk.content.find((c: any) => c.type === 'text');
246
+ text = textPart?.text || '';
247
+ }
248
+
249
+ if (text) {
250
+ await writeResponse(
251
+ ctx,
252
+ formatSSE({
253
+ id: completionId,
254
+ object: 'text_completion',
255
+ created: Math.floor(Date.now() / 1000),
256
+ model: modelName,
257
+ system_fingerprint: null,
258
+ choices: [
259
+ {
260
+ text,
261
+ index: 0,
262
+ logprobs: null,
263
+ finish_reason: null,
264
+ },
265
+ ],
266
+ }),
267
+ );
268
+ }
269
+ 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
+ };
275
+ }
276
+ }
277
+
278
+ // Final chunk
279
+ await writeResponse(
280
+ ctx,
281
+ formatSSE({
282
+ id: completionId,
283
+ object: 'text_completion',
284
+ created: Math.floor(Date.now() / 1000),
285
+ model: modelName,
286
+ system_fingerprint: null,
287
+ choices: [
288
+ {
289
+ text: '',
290
+ index: 0,
291
+ logprobs: null,
292
+ finish_reason: 'stop',
293
+ },
294
+ ],
295
+ }),
296
+ );
297
+
298
+ await writeResponse(ctx, formatSSEDone());
299
+ ctx.state.aiApiStreamResult = { succeeded: true, id: completionId, usage };
300
+ } catch (err) {
301
+ ctx.log.error('AI API completions streaming error:', err);
302
+ if (!ctx.res.destroyed && !ctx.res.writableEnded) {
303
+ await writeResponse(
304
+ ctx,
305
+ formatSSE({
306
+ error: {
307
+ message: getErrorMessage(err, 'Streaming error'),
308
+ type: 'server_error',
309
+ },
310
+ }),
311
+ );
312
+ }
313
+ ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, usage, errorCode: 'stream_error' };
314
+ } finally {
315
+ requestAbort.dispose();
316
+ if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
317
+ }
318
+ }
319
+
320
+ function getErrorMessage(error: unknown, fallback: string) {
321
+ return error instanceof Error && error.message ? error.message : fallback;
322
+ }