plugin-ai-api 1.0.3 → 1.0.6
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/README.md +15 -1
- package/client-v2.d.ts +2 -0
- package/client-v2.js +1 -0
- package/dist/client/778.5c452944cb747975.js +10 -0
- package/dist/client/950.83390c5f1d5a97fb.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +10 -0
- package/dist/client-v2/index.js +10 -0
- package/dist/externalVersion.js +9 -8
- package/package.json +32 -14
- package/src/client/AiApiConfigPage.tsx +309 -0
- package/src/client/client.d.ts +258 -0
- package/src/client/components/AiApiRolePermissions.tsx +169 -0
- package/src/client/index.tsx +10 -0
- package/src/client/locale.ts +21 -0
- package/src/client/models/index.ts +12 -0
- package/src/client/plugin.tsx +48 -0
- package/src/client-v2/index.tsx +1 -0
- package/src/client-v2/plugin.tsx +24 -0
- package/src/index.ts +11 -0
- package/src/locale/en-US.json +10 -0
- package/src/locale/zh-CN.json +10 -0
- package/src/server/collections/.gitkeep +0 -0
- package/src/server/collections/ai-api-config.ts +51 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -0
- package/src/server/index.ts +10 -0
- package/src/server/middleware/rate-limit.ts +70 -0
- package/src/server/middleware/role-permission.ts +66 -0
- package/src/server/plugin.ts +89 -0
- package/src/server/resource/ai-api-config.ts +74 -0
- package/src/server/routes/agent-completions.ts +428 -0
- package/src/server/routes/auth.ts +111 -0
- package/src/server/routes/chat-completions.ts +318 -0
- package/src/server/routes/completions.ts +299 -0
- package/src/server/routes/embeddings.ts +191 -0
- package/src/server/routes/models.ts +195 -0
- package/src/server/routes/router.ts +283 -0
- package/src/server/utils/openai-format.ts +142 -0
- package/src/server/utils/rate-limiter.ts +83 -0
- package/src/server/utils/resolve-service.ts +82 -0
- package/src/swagger.ts +325 -0
- package/dist/client/23.e96ecf13e6072dce.js +0 -10
- package/dist/client/503.29bcdb426b01e715.js +0 -10
|
@@ -0,0 +1,318 @@
|
|
|
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 {
|
|
12
|
+
generateCompletionId,
|
|
13
|
+
toOpenAIResponse,
|
|
14
|
+
toOpenAIStreamChunk,
|
|
15
|
+
toOpenAIError,
|
|
16
|
+
formatSSE,
|
|
17
|
+
formatSSEDone,
|
|
18
|
+
} from '../utils/openai-format';
|
|
19
|
+
import { resolveModelString } from '../utils/resolve-service';
|
|
20
|
+
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
21
|
+
import type PluginAiApiServer from '../plugin';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* POST /api/ai-llm/v1/chat/completions
|
|
25
|
+
*
|
|
26
|
+
* Handles OpenAI-compatible chat completion requests.
|
|
27
|
+
* Supports both streaming (SSE) and non-streaming modes.
|
|
28
|
+
*/
|
|
29
|
+
export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiServer) {
|
|
30
|
+
const body = ctx.request.body as any;
|
|
31
|
+
|
|
32
|
+
// ─── Validate request ───
|
|
33
|
+
if (!body?.model) {
|
|
34
|
+
ctx.status = 400;
|
|
35
|
+
ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!body?.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
|
40
|
+
ctx.status = 400;
|
|
41
|
+
ctx.body = toOpenAIError(400, "'messages' must be a non-empty array", 'invalid_request_error', 'missing_messages');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Reject unsupported n parameter ───
|
|
46
|
+
if (body.n !== undefined && body.n !== null && body.n !== 1) {
|
|
47
|
+
ctx.status = 400;
|
|
48
|
+
ctx.body = toOpenAIError(
|
|
49
|
+
400,
|
|
50
|
+
`The 'n' parameter value ${body.n} is not supported. ` +
|
|
51
|
+
`This API gateway always returns exactly one completion (n=1). Please omit 'n' or set it to 1.`,
|
|
52
|
+
'invalid_request_error',
|
|
53
|
+
'unsupported_parameter',
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stream = body.stream === true;
|
|
59
|
+
|
|
60
|
+
// ─── Resolve model string against DB ───
|
|
61
|
+
const resolved = await resolveModelString(ctx, body.model);
|
|
62
|
+
if (!resolved) {
|
|
63
|
+
ctx.status = 404;
|
|
64
|
+
ctx.body = toOpenAIError(
|
|
65
|
+
404,
|
|
66
|
+
`Could not resolve model '${body.model}'. Format: 'serviceName/modelId'. Use GET /v1/models to see available models.`,
|
|
67
|
+
'invalid_request_error',
|
|
68
|
+
'model_not_found',
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { service, modelId } = resolved;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const aiPlugin = ctx.app.pm.get('ai') as any;
|
|
77
|
+
if (!aiPlugin) {
|
|
78
|
+
ctx.status = 500;
|
|
79
|
+
ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (service.enabled === false) {
|
|
84
|
+
ctx.status = 404;
|
|
85
|
+
ctx.body = toOpenAIError(
|
|
86
|
+
404,
|
|
87
|
+
`LLM service '${service.title || service.name}' is disabled`,
|
|
88
|
+
'invalid_request_error',
|
|
89
|
+
'model_not_found',
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Check whitelist ───
|
|
95
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
96
|
+
if (config?.enabledLlmServices?.length) {
|
|
97
|
+
const serviceName = service.name;
|
|
98
|
+
const serviceTitle = service.title;
|
|
99
|
+
const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
|
|
100
|
+
if (!isAllowed) {
|
|
101
|
+
ctx.status = 403;
|
|
102
|
+
ctx.body = toOpenAIError(
|
|
103
|
+
403,
|
|
104
|
+
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
105
|
+
'invalid_request_error',
|
|
106
|
+
'model_not_available',
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Create LLM provider instance ───
|
|
113
|
+
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
114
|
+
if (!providerMeta) {
|
|
115
|
+
ctx.status = 500;
|
|
116
|
+
ctx.body = toOpenAIError(500, `Provider '${service.provider}' not registered`, 'server_error');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const modelOptions: Record<string, any> = {
|
|
121
|
+
model: modelId,
|
|
122
|
+
llmService: service.name,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Pass through optional parameters
|
|
126
|
+
if (body.temperature !== undefined) modelOptions.temperature = body.temperature;
|
|
127
|
+
if (body.top_p !== undefined) modelOptions.topP = body.top_p;
|
|
128
|
+
if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
|
|
129
|
+
if (body.frequency_penalty !== undefined) modelOptions.frequencyPenalty = body.frequency_penalty;
|
|
130
|
+
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
131
|
+
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
132
|
+
|
|
133
|
+
const Provider = providerMeta.provider;
|
|
134
|
+
const provider = new Provider({
|
|
135
|
+
app: ctx.app,
|
|
136
|
+
serviceOptions: service.options,
|
|
137
|
+
modelOptions,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─── Build system prompt from AI Employee ───
|
|
141
|
+
let systemPrompt = '';
|
|
142
|
+
if (config?.defaultAiEmployee) {
|
|
143
|
+
// Check role is allowed to use this employee
|
|
144
|
+
if (!checkEmployeeAccess(ctx, config.defaultAiEmployee)) {
|
|
145
|
+
ctx.status = 403;
|
|
146
|
+
ctx.body = toOpenAIError(
|
|
147
|
+
403,
|
|
148
|
+
`Role is not permitted to use AI Employee '${config.defaultAiEmployee}'. ` +
|
|
149
|
+
`An admin must grant access in Settings → Users & Permissions → [Role] → AI API.`,
|
|
150
|
+
'permission_denied',
|
|
151
|
+
'employee_not_permitted',
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
156
|
+
filter: { username: config.defaultAiEmployee },
|
|
157
|
+
});
|
|
158
|
+
if (employee) {
|
|
159
|
+
systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Build messages (inject system prompt if not provided by client) ───
|
|
164
|
+
const messages = [...body.messages];
|
|
165
|
+
const hasSystemMessage = messages.some((m: any) => m.role === 'system');
|
|
166
|
+
if (systemPrompt && !hasSystemMessage) {
|
|
167
|
+
messages.unshift({ role: 'system', content: systemPrompt });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Build message tuples for LangChain model ───
|
|
171
|
+
// LangChain chat models accept [role, content] tuples or BaseMessage objects.
|
|
172
|
+
// We use tuples to avoid importing @langchain/core directly.
|
|
173
|
+
const langchainMessages = messages.map((msg: any) => {
|
|
174
|
+
const role = msg.role === 'assistant' ? 'ai' : msg.role;
|
|
175
|
+
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
176
|
+
return [role, content] as [string, string];
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const completionId = generateCompletionId();
|
|
180
|
+
const chatModel = provider.createModel();
|
|
181
|
+
|
|
182
|
+
if (stream) {
|
|
183
|
+
// ─── Streaming mode ───
|
|
184
|
+
await handleStreamingCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
185
|
+
} else {
|
|
186
|
+
// ─── Non-streaming mode ───
|
|
187
|
+
await handleNonStreamingCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
ctx.log.error('AI API chat completions error:', err);
|
|
191
|
+
if (!ctx.res.headersSent) {
|
|
192
|
+
ctx.status = 500;
|
|
193
|
+
ctx.body = toOpenAIError(500, err.message || 'Internal server error', 'server_error');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Non-streaming handler ───
|
|
199
|
+
|
|
200
|
+
async function handleNonStreamingCompletion(
|
|
201
|
+
ctx: Context,
|
|
202
|
+
chatModel: any,
|
|
203
|
+
messages: any[],
|
|
204
|
+
completionId: string,
|
|
205
|
+
modelName: string,
|
|
206
|
+
) {
|
|
207
|
+
const result = await chatModel.invoke(messages);
|
|
208
|
+
|
|
209
|
+
let content = '';
|
|
210
|
+
if (typeof result.content === 'string') {
|
|
211
|
+
content = result.content;
|
|
212
|
+
} else if (Array.isArray(result.content)) {
|
|
213
|
+
// Handle array content (e.g. OpenAI responses API)
|
|
214
|
+
const textPart = result.content.find((c: any) => c.type === 'text');
|
|
215
|
+
content = textPart?.text || JSON.stringify(result.content);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Extract usage if available
|
|
219
|
+
const usage = result.usage_metadata
|
|
220
|
+
? {
|
|
221
|
+
prompt_tokens: result.usage_metadata.input_tokens || 0,
|
|
222
|
+
completion_tokens: result.usage_metadata.output_tokens || 0,
|
|
223
|
+
total_tokens: result.usage_metadata.total_tokens || 0,
|
|
224
|
+
}
|
|
225
|
+
: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
226
|
+
|
|
227
|
+
ctx.status = 200;
|
|
228
|
+
ctx.body = toOpenAIResponse({
|
|
229
|
+
id: completionId,
|
|
230
|
+
model: modelName,
|
|
231
|
+
content,
|
|
232
|
+
usage,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Streaming handler ───
|
|
237
|
+
|
|
238
|
+
async function handleStreamingCompletion(
|
|
239
|
+
ctx: Context,
|
|
240
|
+
chatModel: any,
|
|
241
|
+
messages: any[],
|
|
242
|
+
completionId: string,
|
|
243
|
+
modelName: string,
|
|
244
|
+
) {
|
|
245
|
+
// Set SSE headers
|
|
246
|
+
ctx.set({
|
|
247
|
+
'Content-Type': 'text/event-stream',
|
|
248
|
+
'Cache-Control': 'no-cache',
|
|
249
|
+
Connection: 'keep-alive',
|
|
250
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
251
|
+
});
|
|
252
|
+
ctx.status = 200;
|
|
253
|
+
|
|
254
|
+
// Send initial chunk with role
|
|
255
|
+
ctx.res.write(
|
|
256
|
+
formatSSE(
|
|
257
|
+
toOpenAIStreamChunk({
|
|
258
|
+
id: completionId,
|
|
259
|
+
model: modelName,
|
|
260
|
+
delta: { role: 'assistant', content: '' },
|
|
261
|
+
}),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const stream = await chatModel.stream(messages);
|
|
267
|
+
|
|
268
|
+
for await (const chunk of stream) {
|
|
269
|
+
let content = '';
|
|
270
|
+
if (typeof chunk.content === 'string') {
|
|
271
|
+
content = chunk.content;
|
|
272
|
+
} else if (Array.isArray(chunk.content)) {
|
|
273
|
+
const textPart = chunk.content.find((c: any) => c.type === 'text');
|
|
274
|
+
content = textPart?.text || '';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (content) {
|
|
278
|
+
ctx.res.write(
|
|
279
|
+
formatSSE(
|
|
280
|
+
toOpenAIStreamChunk({
|
|
281
|
+
id: completionId,
|
|
282
|
+
model: modelName,
|
|
283
|
+
delta: { content },
|
|
284
|
+
}),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Send finish chunk
|
|
291
|
+
ctx.res.write(
|
|
292
|
+
formatSSE(
|
|
293
|
+
toOpenAIStreamChunk({
|
|
294
|
+
id: completionId,
|
|
295
|
+
model: modelName,
|
|
296
|
+
delta: {},
|
|
297
|
+
finishReason: 'stop',
|
|
298
|
+
}),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Send [DONE]
|
|
303
|
+
ctx.res.write(formatSSEDone());
|
|
304
|
+
} catch (err) {
|
|
305
|
+
ctx.log.error('AI API streaming error:', err);
|
|
306
|
+
// Send error as SSE event before closing
|
|
307
|
+
ctx.res.write(
|
|
308
|
+
formatSSE({
|
|
309
|
+
error: {
|
|
310
|
+
message: err.message || 'Streaming error',
|
|
311
|
+
type: 'server_error',
|
|
312
|
+
},
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
} finally {
|
|
316
|
+
ctx.res.end();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,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 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
|
+
}
|