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.
Files changed (43) hide show
  1. package/README.md +15 -1
  2. package/client-v2.d.ts +2 -0
  3. package/client-v2.js +1 -0
  4. package/dist/client/778.5c452944cb747975.js +10 -0
  5. package/dist/client/950.83390c5f1d5a97fb.js +10 -0
  6. package/dist/client/index.js +1 -1
  7. package/dist/client-v2/950.42b30b5cc9e32b8f.js +10 -0
  8. package/dist/client-v2/index.js +10 -0
  9. package/dist/externalVersion.js +9 -8
  10. package/package.json +32 -14
  11. package/src/client/AiApiConfigPage.tsx +309 -0
  12. package/src/client/client.d.ts +258 -0
  13. package/src/client/components/AiApiRolePermissions.tsx +169 -0
  14. package/src/client/index.tsx +10 -0
  15. package/src/client/locale.ts +21 -0
  16. package/src/client/models/index.ts +12 -0
  17. package/src/client/plugin.tsx +48 -0
  18. package/src/client-v2/index.tsx +1 -0
  19. package/src/client-v2/plugin.tsx +24 -0
  20. package/src/index.ts +11 -0
  21. package/src/locale/en-US.json +10 -0
  22. package/src/locale/zh-CN.json +10 -0
  23. package/src/server/collections/.gitkeep +0 -0
  24. package/src/server/collections/ai-api-config.ts +51 -0
  25. package/src/server/collections/ai-api-role-permissions.ts +41 -0
  26. package/src/server/index.ts +10 -0
  27. package/src/server/middleware/rate-limit.ts +70 -0
  28. package/src/server/middleware/role-permission.ts +66 -0
  29. package/src/server/plugin.ts +89 -0
  30. package/src/server/resource/ai-api-config.ts +74 -0
  31. package/src/server/routes/agent-completions.ts +428 -0
  32. package/src/server/routes/auth.ts +111 -0
  33. package/src/server/routes/chat-completions.ts +318 -0
  34. package/src/server/routes/completions.ts +299 -0
  35. package/src/server/routes/embeddings.ts +191 -0
  36. package/src/server/routes/models.ts +195 -0
  37. package/src/server/routes/router.ts +283 -0
  38. package/src/server/utils/openai-format.ts +142 -0
  39. package/src/server/utils/rate-limiter.ts +83 -0
  40. package/src/server/utils/resolve-service.ts +82 -0
  41. package/src/swagger.ts +325 -0
  42. package/dist/client/23.e96ecf13e6072dce.js +0 -10
  43. 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
+ }