plugin-ai-api 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/externalVersion.js +9 -9
- package/dist/server/collections/ai-api-usage-records.js +63 -0
- package/dist/server/middleware/role-permission.js +15 -5
- package/dist/server/plugin.js +8 -1
- package/dist/server/routes/agent-completions.js +108 -42
- package/dist/server/routes/auth.js +37 -7
- package/dist/server/routes/chat-completions.js +103 -19
- package/dist/server/routes/completions.js +37 -15
- package/dist/server/routes/router.js +41 -4
- package/dist/server/usage.js +81 -0
- package/dist/server/utils/openai-format.js +11 -2
- package/dist/server/utils/streaming.js +80 -0
- package/dist/swagger.js +38 -4
- package/package.json +3 -2
- package/src/server/__tests__/openai-format.test.ts +52 -0
- package/src/server/collections/ai-api-usage-records.ts +33 -0
- package/src/server/middleware/role-permission.ts +79 -66
- package/src/server/plugin.ts +99 -89
- package/src/server/routes/agent-completions.ts +121 -54
- package/src/server/routes/auth.ts +142 -111
- package/src/server/routes/chat-completions.ts +406 -318
- package/src/server/routes/completions.ts +322 -299
- package/src/server/routes/router.ts +320 -283
- package/src/server/usage.ts +55 -0
- package/src/server/utils/ai-employee-runtime.ts +1 -1
- package/src/server/utils/openai-format.ts +164 -142
- package/src/server/utils/streaming.ts +46 -0
- package/src/swagger.ts +359 -325
|
@@ -1,318 +1,406 @@
|
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (body.
|
|
130
|
-
if (body.
|
|
131
|
-
if (body.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
'
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// ───
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
OpenAIToolCall,
|
|
19
|
+
OpenAIToolCallChunk,
|
|
20
|
+
} from '../utils/openai-format';
|
|
21
|
+
import { resolveModelString } from '../utils/resolve-service';
|
|
22
|
+
import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
|
|
23
|
+
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
24
|
+
import type PluginAiApiServer from '../plugin';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* POST /api/ai-llm/v1/chat/completions
|
|
28
|
+
*
|
|
29
|
+
* Handles OpenAI-compatible chat completion requests.
|
|
30
|
+
* Supports both streaming (SSE) and non-streaming modes.
|
|
31
|
+
*/
|
|
32
|
+
export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiServer) {
|
|
33
|
+
const body = ctx.request.body as any;
|
|
34
|
+
|
|
35
|
+
// ─── Validate request ───
|
|
36
|
+
if (!body?.model) {
|
|
37
|
+
ctx.status = 400;
|
|
38
|
+
ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!body?.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
|
43
|
+
ctx.status = 400;
|
|
44
|
+
ctx.body = toOpenAIError(400, "'messages' must be a non-empty array", 'invalid_request_error', 'missing_messages');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Reject unsupported n parameter ───
|
|
49
|
+
if (body.n !== undefined && body.n !== null && body.n !== 1) {
|
|
50
|
+
ctx.status = 400;
|
|
51
|
+
ctx.body = toOpenAIError(
|
|
52
|
+
400,
|
|
53
|
+
`The 'n' parameter value ${body.n} is not supported. ` +
|
|
54
|
+
`This API gateway always returns exactly one completion (n=1). Please omit 'n' or set it to 1.`,
|
|
55
|
+
'invalid_request_error',
|
|
56
|
+
'unsupported_parameter',
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stream = isStreamingRequested(body.stream);
|
|
62
|
+
|
|
63
|
+
// ─── Resolve model string against DB ───
|
|
64
|
+
const resolved = await resolveModelString(ctx, body.model);
|
|
65
|
+
if (!resolved) {
|
|
66
|
+
ctx.status = 404;
|
|
67
|
+
ctx.body = toOpenAIError(
|
|
68
|
+
404,
|
|
69
|
+
`Could not resolve model '${body.model}'. Format: 'serviceName/modelId'. Use GET /v1/models to see available models.`,
|
|
70
|
+
'invalid_request_error',
|
|
71
|
+
'model_not_found',
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { service, modelId } = resolved;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const aiPlugin = ctx.app.pm.get('ai') as any;
|
|
80
|
+
if (!aiPlugin) {
|
|
81
|
+
ctx.status = 500;
|
|
82
|
+
ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (service.enabled === false) {
|
|
87
|
+
ctx.status = 404;
|
|
88
|
+
ctx.body = toOpenAIError(
|
|
89
|
+
404,
|
|
90
|
+
`LLM service '${service.title || service.name}' is disabled`,
|
|
91
|
+
'invalid_request_error',
|
|
92
|
+
'model_not_found',
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Check whitelist ───
|
|
98
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
99
|
+
if (config?.enabledLlmServices?.length) {
|
|
100
|
+
const serviceName = service.name;
|
|
101
|
+
const serviceTitle = service.title;
|
|
102
|
+
const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
|
|
103
|
+
if (!isAllowed) {
|
|
104
|
+
ctx.status = 403;
|
|
105
|
+
ctx.body = toOpenAIError(
|
|
106
|
+
403,
|
|
107
|
+
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
108
|
+
'invalid_request_error',
|
|
109
|
+
'model_not_available',
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Create LLM provider instance ───
|
|
116
|
+
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
117
|
+
if (!providerMeta) {
|
|
118
|
+
ctx.status = 500;
|
|
119
|
+
ctx.body = toOpenAIError(500, `Provider '${service.provider}' not registered`, 'server_error');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const modelOptions: Record<string, any> = {
|
|
124
|
+
model: modelId,
|
|
125
|
+
llmService: service.name,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Pass through optional parameters
|
|
129
|
+
if (body.temperature !== undefined) modelOptions.temperature = body.temperature;
|
|
130
|
+
if (body.top_p !== undefined) modelOptions.topP = body.top_p;
|
|
131
|
+
if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
|
|
132
|
+
if (body.frequency_penalty !== undefined) modelOptions.frequencyPenalty = body.frequency_penalty;
|
|
133
|
+
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
134
|
+
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
135
|
+
|
|
136
|
+
const Provider = providerMeta.provider;
|
|
137
|
+
const provider = new Provider({
|
|
138
|
+
app: ctx.app,
|
|
139
|
+
serviceOptions: service.options,
|
|
140
|
+
modelOptions,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ─── Build system prompt from AI Employee ───
|
|
144
|
+
let systemPrompt = '';
|
|
145
|
+
if (config?.defaultAiEmployee) {
|
|
146
|
+
// Check role is allowed to use this employee
|
|
147
|
+
if (!checkEmployeeAccess(ctx, config.defaultAiEmployee)) {
|
|
148
|
+
ctx.status = 403;
|
|
149
|
+
ctx.body = toOpenAIError(
|
|
150
|
+
403,
|
|
151
|
+
`Role is not permitted to use AI Employee '${config.defaultAiEmployee}'. ` +
|
|
152
|
+
`An admin must grant access in Settings → Users & Permissions → [Role] → AI API.`,
|
|
153
|
+
'permission_denied',
|
|
154
|
+
'employee_not_permitted',
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
159
|
+
filter: { username: config.defaultAiEmployee },
|
|
160
|
+
});
|
|
161
|
+
if (employee) {
|
|
162
|
+
systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Build messages (inject system prompt if not provided by client) ───
|
|
167
|
+
const messages = [...body.messages];
|
|
168
|
+
const hasSystemMessage = messages.some((m: any) => m.role === 'system');
|
|
169
|
+
if (systemPrompt && !hasSystemMessage) {
|
|
170
|
+
messages.unshift({ role: 'system', content: systemPrompt });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Build message tuples for LangChain model ───
|
|
174
|
+
// LangChain chat models accept [role, content] tuples or BaseMessage objects.
|
|
175
|
+
// We use tuples to avoid importing @langchain/core directly.
|
|
176
|
+
const langchainMessages = messages.map((msg: any) => {
|
|
177
|
+
const role = msg.role === 'assistant' ? 'ai' : msg.role;
|
|
178
|
+
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
179
|
+
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
180
|
+
return {
|
|
181
|
+
role,
|
|
182
|
+
content,
|
|
183
|
+
tool_calls: msg.tool_calls,
|
|
184
|
+
additional_kwargs: { tool_calls: msg.tool_calls },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (msg.role === 'tool') {
|
|
188
|
+
return { role: 'tool', content, tool_call_id: msg.tool_call_id, name: msg.name };
|
|
189
|
+
}
|
|
190
|
+
return [role, content] as [string, string];
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const completionId = generateCompletionId();
|
|
194
|
+
const baseModel = provider.createModel();
|
|
195
|
+
const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice);
|
|
196
|
+
|
|
197
|
+
if (stream) {
|
|
198
|
+
// ─── Streaming mode ───
|
|
199
|
+
await handleStreamingCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
200
|
+
} else {
|
|
201
|
+
// ─── Non-streaming mode ───
|
|
202
|
+
await handleNonStreamingCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
ctx.log.error('AI API chat completions error:', err);
|
|
206
|
+
if (!ctx.res.headersSent) {
|
|
207
|
+
ctx.status = 500;
|
|
208
|
+
ctx.body = toOpenAIError(500, getErrorMessage(err, 'Internal server error'), 'server_error');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Non-streaming handler ───
|
|
214
|
+
|
|
215
|
+
async function handleNonStreamingCompletion(
|
|
216
|
+
ctx: Context,
|
|
217
|
+
chatModel: any,
|
|
218
|
+
messages: any[],
|
|
219
|
+
completionId: string,
|
|
220
|
+
modelName: string,
|
|
221
|
+
) {
|
|
222
|
+
const result = await chatModel.invoke(messages);
|
|
223
|
+
|
|
224
|
+
let content = '';
|
|
225
|
+
if (typeof result.content === 'string') {
|
|
226
|
+
content = result.content;
|
|
227
|
+
} else if (Array.isArray(result.content)) {
|
|
228
|
+
// Handle array content (e.g. OpenAI responses API)
|
|
229
|
+
const textPart = result.content.find((c: any) => c.type === 'text');
|
|
230
|
+
content = textPart?.text || JSON.stringify(result.content);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Extract usage if available
|
|
234
|
+
const usage = result.usage_metadata
|
|
235
|
+
? {
|
|
236
|
+
prompt_tokens: result.usage_metadata.input_tokens || 0,
|
|
237
|
+
completion_tokens: result.usage_metadata.output_tokens || 0,
|
|
238
|
+
total_tokens: result.usage_metadata.total_tokens || 0,
|
|
239
|
+
}
|
|
240
|
+
: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
241
|
+
|
|
242
|
+
ctx.status = 200;
|
|
243
|
+
const toolCalls = normalizeToolCalls(result.tool_calls);
|
|
244
|
+
ctx.body = toOpenAIResponse({
|
|
245
|
+
id: completionId,
|
|
246
|
+
model: modelName,
|
|
247
|
+
content,
|
|
248
|
+
usage,
|
|
249
|
+
toolCalls,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Streaming handler ───
|
|
254
|
+
|
|
255
|
+
async function handleStreamingCompletion(
|
|
256
|
+
ctx: Context,
|
|
257
|
+
chatModel: any,
|
|
258
|
+
messages: any[],
|
|
259
|
+
completionId: string,
|
|
260
|
+
modelName: string,
|
|
261
|
+
) {
|
|
262
|
+
// Set SSE headers
|
|
263
|
+
ctx.set({
|
|
264
|
+
'Content-Type': 'text/event-stream',
|
|
265
|
+
'Cache-Control': 'no-cache',
|
|
266
|
+
Connection: 'keep-alive',
|
|
267
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
268
|
+
});
|
|
269
|
+
ctx.status = 200;
|
|
270
|
+
|
|
271
|
+
// Send initial chunk with role
|
|
272
|
+
await writeResponse(
|
|
273
|
+
ctx,
|
|
274
|
+
formatSSE(
|
|
275
|
+
toOpenAIStreamChunk({
|
|
276
|
+
id: completionId,
|
|
277
|
+
model: modelName,
|
|
278
|
+
delta: { role: 'assistant', content: '' },
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const requestAbort = createRequestAbortController(ctx);
|
|
284
|
+
let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined;
|
|
285
|
+
let finishReason = 'stop';
|
|
286
|
+
try {
|
|
287
|
+
const stream = await chatModel.stream(messages, { signal: requestAbort.signal });
|
|
288
|
+
|
|
289
|
+
for await (const chunk of stream) {
|
|
290
|
+
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
291
|
+
let content = '';
|
|
292
|
+
if (typeof chunk.content === 'string') {
|
|
293
|
+
content = chunk.content;
|
|
294
|
+
} else if (Array.isArray(chunk.content)) {
|
|
295
|
+
const textPart = chunk.content.find((c: any) => c.type === 'text');
|
|
296
|
+
content = textPart?.text || '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (content) {
|
|
300
|
+
await writeResponse(
|
|
301
|
+
ctx,
|
|
302
|
+
formatSSE(
|
|
303
|
+
toOpenAIStreamChunk({
|
|
304
|
+
id: completionId,
|
|
305
|
+
model: modelName,
|
|
306
|
+
delta: { content },
|
|
307
|
+
}),
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
|
|
313
|
+
if (toolCallChunks.length) {
|
|
314
|
+
finishReason = 'tool_calls';
|
|
315
|
+
await writeResponse(
|
|
316
|
+
ctx,
|
|
317
|
+
formatSSE(toOpenAIStreamChunk({ id: completionId, model: modelName, delta: { tool_calls: toolCallChunks } })),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (chunk.usage_metadata) {
|
|
321
|
+
usage = {
|
|
322
|
+
prompt_tokens: chunk.usage_metadata.input_tokens || 0,
|
|
323
|
+
completion_tokens: chunk.usage_metadata.output_tokens || 0,
|
|
324
|
+
total_tokens: chunk.usage_metadata.total_tokens || 0,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Send finish chunk
|
|
330
|
+
await writeResponse(
|
|
331
|
+
ctx,
|
|
332
|
+
formatSSE(
|
|
333
|
+
toOpenAIStreamChunk({
|
|
334
|
+
id: completionId,
|
|
335
|
+
model: modelName,
|
|
336
|
+
delta: {},
|
|
337
|
+
finishReason,
|
|
338
|
+
}),
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Send [DONE]
|
|
343
|
+
await writeResponse(ctx, formatSSEDone());
|
|
344
|
+
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId, usage };
|
|
345
|
+
} catch (err) {
|
|
346
|
+
ctx.log.error('AI API streaming error:', err);
|
|
347
|
+
// Send error as SSE event before closing
|
|
348
|
+
if (!ctx.res.destroyed && !ctx.res.writableEnded) {
|
|
349
|
+
await writeResponse(
|
|
350
|
+
ctx,
|
|
351
|
+
formatSSE({
|
|
352
|
+
error: {
|
|
353
|
+
message: getErrorMessage(err, 'Streaming error'),
|
|
354
|
+
type: 'server_error',
|
|
355
|
+
},
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, usage, errorCode: 'stream_error' };
|
|
360
|
+
} finally {
|
|
361
|
+
requestAbort.dispose();
|
|
362
|
+
if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getErrorMessage(error: unknown, fallback: string) {
|
|
367
|
+
return error instanceof Error && error.message ? error.message : fallback;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function bindRequestTools(chatModel: any, tools: unknown, toolChoice: unknown) {
|
|
371
|
+
if (!Array.isArray(tools) || tools.length === 0) return chatModel;
|
|
372
|
+
if (typeof chatModel.bindTools !== 'function') {
|
|
373
|
+
throw new Error('The selected LLM provider does not support tool calling');
|
|
374
|
+
}
|
|
375
|
+
return chatModel.bindTools(tools, toolChoice === undefined ? undefined : { tool_choice: toolChoice });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
|
|
379
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
380
|
+
return value.map((call: any) => ({
|
|
381
|
+
id: String(call.id || ''),
|
|
382
|
+
type: 'function',
|
|
383
|
+
function: {
|
|
384
|
+
name: String(call.name || call.function?.name || ''),
|
|
385
|
+
arguments: serializeToolArguments(call.args ?? call.function?.arguments),
|
|
386
|
+
},
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function normalizeToolCallChunks(value: unknown): OpenAIToolCallChunk[] {
|
|
391
|
+
if (!Array.isArray(value)) return [];
|
|
392
|
+
return value.map((call: any, fallbackIndex) => ({
|
|
393
|
+
index: typeof call.index === 'number' ? call.index : fallbackIndex,
|
|
394
|
+
...(call.id ? { id: String(call.id), type: 'function' as const } : {}),
|
|
395
|
+
function: {
|
|
396
|
+
...(call.name ? { name: String(call.name) } : {}),
|
|
397
|
+
...(call.args !== undefined || call.function?.arguments !== undefined
|
|
398
|
+
? { arguments: serializeToolArguments(call.args ?? call.function?.arguments) }
|
|
399
|
+
: {}),
|
|
400
|
+
},
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function serializeToolArguments(value: unknown) {
|
|
405
|
+
return typeof value === 'string' ? value : JSON.stringify(value ?? {});
|
|
406
|
+
}
|