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,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
- } 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
- }
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
+ }