plugin-ai-api 1.0.9 → 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/routes/agent-completions.js +108 -42
- package/dist/server/routes/chat-completions.js +103 -19
- package/dist/server/routes/completions.js +37 -15
- package/dist/server/routes/router.js +20 -4
- package/dist/server/usage.js +5 -4
- 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/routes/agent-completions.ts +121 -54
- package/src/server/routes/chat-completions.ts +406 -318
- package/src/server/routes/completions.ts +322 -299
- package/src/server/routes/router.ts +19 -3
- package/src/server/usage.ts +7 -4
- 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
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
12
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
13
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
14
|
+
var __export = (target, all) => {
|
|
15
|
+
for (var name in all)
|
|
16
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
17
|
+
};
|
|
18
|
+
var __copyProps = (to, from, except, desc) => {
|
|
19
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
20
|
+
for (let key of __getOwnPropNames(from))
|
|
21
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
22
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
23
|
+
}
|
|
24
|
+
return to;
|
|
25
|
+
};
|
|
26
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
27
|
+
var streaming_exports = {};
|
|
28
|
+
__export(streaming_exports, {
|
|
29
|
+
createRequestAbortController: () => createRequestAbortController,
|
|
30
|
+
isStreamingRequested: () => isStreamingRequested,
|
|
31
|
+
writeResponse: () => writeResponse
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(streaming_exports);
|
|
34
|
+
function isStreamingRequested(value) {
|
|
35
|
+
return value !== false;
|
|
36
|
+
}
|
|
37
|
+
function createRequestAbortController(ctx) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const abort = () => {
|
|
40
|
+
if (!ctx.res.writableEnded) controller.abort(new Error("Client disconnected"));
|
|
41
|
+
};
|
|
42
|
+
ctx.req.once("aborted", abort);
|
|
43
|
+
ctx.res.once("close", abort);
|
|
44
|
+
return {
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
dispose() {
|
|
47
|
+
ctx.req.off("aborted", abort);
|
|
48
|
+
ctx.res.off("close", abort);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function writeResponse(ctx, data) {
|
|
53
|
+
if (ctx.res.writableEnded || ctx.res.destroyed) return false;
|
|
54
|
+
if (!ctx.res.write(data)) await waitForDrain(ctx);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
function waitForDrain(ctx) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const cleanup = () => {
|
|
60
|
+
ctx.res.off("drain", onDrain);
|
|
61
|
+
ctx.res.off("close", onClose);
|
|
62
|
+
};
|
|
63
|
+
const onDrain = () => {
|
|
64
|
+
cleanup();
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
const onClose = () => {
|
|
68
|
+
cleanup();
|
|
69
|
+
reject(new Error("Client disconnected"));
|
|
70
|
+
};
|
|
71
|
+
ctx.res.once("drain", onDrain);
|
|
72
|
+
ctx.res.once("close", onClose);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
76
|
+
0 && (module.exports = {
|
|
77
|
+
createRequestAbortController,
|
|
78
|
+
isStreamingRequested,
|
|
79
|
+
writeResponse
|
|
80
|
+
});
|
package/dist/swagger.js
CHANGED
|
@@ -183,7 +183,7 @@ var swagger_default = {
|
|
|
183
183
|
model: { type: "string" },
|
|
184
184
|
prompt: { type: "string" },
|
|
185
185
|
max_tokens: { type: "integer" },
|
|
186
|
-
stream: { type: "boolean", default:
|
|
186
|
+
stream: { type: "boolean", default: true }
|
|
187
187
|
},
|
|
188
188
|
required: ["model", "prompt"]
|
|
189
189
|
}
|
|
@@ -294,7 +294,9 @@ var swagger_default = {
|
|
|
294
294
|
properties: {
|
|
295
295
|
role: { type: "string", enum: ["system", "user", "assistant", "tool"] },
|
|
296
296
|
content: { type: "string" },
|
|
297
|
-
name: { type: "string" }
|
|
297
|
+
name: { type: "string" },
|
|
298
|
+
tool_call_id: { type: "string" },
|
|
299
|
+
tool_calls: { type: "array", items: { $ref: "#/components/schemas/ToolCall" } }
|
|
298
300
|
},
|
|
299
301
|
required: ["role", "content"]
|
|
300
302
|
},
|
|
@@ -303,12 +305,17 @@ var swagger_default = {
|
|
|
303
305
|
properties: {
|
|
304
306
|
model: { type: "string", example: "openai/gpt-4o" },
|
|
305
307
|
messages: { type: "array", items: { $ref: "#/components/schemas/ChatMessage" } },
|
|
306
|
-
stream: { type: "boolean", default:
|
|
308
|
+
stream: { type: "boolean", default: true },
|
|
307
309
|
temperature: { type: "number", minimum: 0, maximum: 2 },
|
|
308
310
|
max_tokens: { type: "integer" },
|
|
309
311
|
top_p: { type: "number" },
|
|
310
312
|
frequency_penalty: { type: "number" },
|
|
311
|
-
presence_penalty: { type: "number" }
|
|
313
|
+
presence_penalty: { type: "number" },
|
|
314
|
+
tools: { type: "array", items: { $ref: "#/components/schemas/ToolDefinition" } },
|
|
315
|
+
tool_choice: {
|
|
316
|
+
description: "OpenAI-compatible tool choice: auto, none, required, or a named function choice.",
|
|
317
|
+
oneOf: [{ type: "string", enum: ["auto", "none", "required"] }, { type: "object" }]
|
|
318
|
+
}
|
|
312
319
|
},
|
|
313
320
|
required: ["model", "messages"]
|
|
314
321
|
},
|
|
@@ -339,6 +346,33 @@ var swagger_default = {
|
|
|
339
346
|
}
|
|
340
347
|
}
|
|
341
348
|
}
|
|
349
|
+
},
|
|
350
|
+
ToolDefinition: {
|
|
351
|
+
type: "object",
|
|
352
|
+
properties: {
|
|
353
|
+
type: { type: "string", enum: ["function"] },
|
|
354
|
+
function: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: {
|
|
357
|
+
name: { type: "string" },
|
|
358
|
+
description: { type: "string" },
|
|
359
|
+
parameters: { type: "object" }
|
|
360
|
+
},
|
|
361
|
+
required: ["name", "parameters"]
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
required: ["type", "function"]
|
|
365
|
+
},
|
|
366
|
+
ToolCall: {
|
|
367
|
+
type: "object",
|
|
368
|
+
properties: {
|
|
369
|
+
id: { type: "string" },
|
|
370
|
+
type: { type: "string", enum: ["function"] },
|
|
371
|
+
function: {
|
|
372
|
+
type: "object",
|
|
373
|
+
properties: { name: { type: "string" }, arguments: { type: "string" } }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
342
376
|
}
|
|
343
377
|
}
|
|
344
378
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plugin-ai-api",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"main": "dist/server/index.js",
|
|
5
5
|
"dependencies": {},
|
|
6
6
|
"peerDependencies": {
|
|
@@ -28,5 +28,6 @@
|
|
|
28
28
|
"client.d.ts",
|
|
29
29
|
"client-v2.d.ts",
|
|
30
30
|
"server.d.ts"
|
|
31
|
-
]
|
|
31
|
+
],
|
|
32
|
+
"license": "Apache-2.0"
|
|
32
33
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { toOpenAIResponse, toOpenAIStreamChunk } from '../utils/openai-format';
|
|
2
|
+
import { isStreamingRequested } from '../utils/streaming';
|
|
3
|
+
|
|
4
|
+
describe('AI API OpenAI tool-call formatting', () => {
|
|
5
|
+
it('streams by default and only disables streaming for an explicit false value', () => {
|
|
6
|
+
expect(isStreamingRequested(undefined)).toBe(true);
|
|
7
|
+
expect(isStreamingRequested(true)).toBe(true);
|
|
8
|
+
expect(isStreamingRequested(false)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it('returns tool calls and the matching finish reason for a non-stream response', () => {
|
|
11
|
+
const response = toOpenAIResponse({
|
|
12
|
+
id: 'chatcmpl-1',
|
|
13
|
+
model: 'service/model',
|
|
14
|
+
content: '',
|
|
15
|
+
toolCalls: [
|
|
16
|
+
{
|
|
17
|
+
id: 'call-1',
|
|
18
|
+
type: 'function',
|
|
19
|
+
function: { name: 'get_weather', arguments: '{"city":"Hanoi"}' },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(response.choices[0].finish_reason).toBe('tool_calls');
|
|
25
|
+
expect(response.choices[0].message.tool_calls).toEqual([
|
|
26
|
+
{
|
|
27
|
+
id: 'call-1',
|
|
28
|
+
type: 'function',
|
|
29
|
+
function: { name: 'get_weather', arguments: '{"city":"Hanoi"}' },
|
|
30
|
+
},
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('formats streaming tool-call deltas', () => {
|
|
35
|
+
const chunk = toOpenAIStreamChunk({
|
|
36
|
+
id: 'chatcmpl-1',
|
|
37
|
+
model: 'service/model',
|
|
38
|
+
delta: {
|
|
39
|
+
tool_calls: [
|
|
40
|
+
{
|
|
41
|
+
index: 0,
|
|
42
|
+
id: 'call-1',
|
|
43
|
+
type: 'function',
|
|
44
|
+
function: { name: 'get_weather', arguments: '{"city"' },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(chunk.choices[0].delta.tool_calls?.[0].function?.name).toBe('get_weather');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
toOpenAIError,
|
|
15
15
|
formatSSE,
|
|
16
16
|
formatSSEDone,
|
|
17
|
+
OpenAIToolCallChunk,
|
|
17
18
|
} from '../utils/openai-format';
|
|
18
19
|
import { resolveModelString } from '../utils/resolve-service';
|
|
19
20
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
21
|
+
import { isStreamingRequested } from '../utils/streaming';
|
|
20
22
|
import {
|
|
21
23
|
AgentRuntimeContext,
|
|
22
24
|
createAIEmployeeOptions,
|
|
@@ -132,7 +134,7 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
132
134
|
return;
|
|
133
135
|
}
|
|
134
136
|
|
|
135
|
-
const wantStream = body.stream
|
|
137
|
+
const wantStream = isStreamingRequested(body.stream);
|
|
136
138
|
const lifecycle = getAgentRuntimeLifecycle(ctx);
|
|
137
139
|
let runtimeContext: AgentRuntimeContext | undefined;
|
|
138
140
|
let lifecycleCompleted = false;
|
|
@@ -256,6 +258,17 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
256
258
|
|
|
257
259
|
const originalWrite = ctx.res.write.bind(ctx.res);
|
|
258
260
|
const originalEnd = ctx.res.end.bind(ctx.res);
|
|
261
|
+
const aiPlugin = ctx.app.pm.get('ai') as any;
|
|
262
|
+
const abortAgent = () => {
|
|
263
|
+
if (!ctx.res.writableEnded) {
|
|
264
|
+
aiPlugin?.aiEmployeesManager?.conversationController?.get(String(sessionId))?.abort();
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
ctx.req.once('aborted', abortAgent);
|
|
268
|
+
ctx.res.once('close', abortAgent);
|
|
269
|
+
let streamSucceeded = false;
|
|
270
|
+
let sawToolCalls = false;
|
|
271
|
+
let pendingSse = '';
|
|
259
272
|
|
|
260
273
|
// Intercept end() — prevent AIEmployee from terminating the stream early.
|
|
261
274
|
// We restore and call it ourselves in the finally block.
|
|
@@ -269,46 +282,78 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
269
282
|
|
|
270
283
|
// Intercept write() — translate NocoBase SSE → OpenAI SSE
|
|
271
284
|
(ctx.res as any).write = (data: Buffer | string): boolean => {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
pendingSse += typeof data === 'string' ? data : data.toString('utf8');
|
|
286
|
+
const frames = pendingSse.split('\n\n');
|
|
287
|
+
pendingSse = frames.pop() || '';
|
|
288
|
+
|
|
289
|
+
for (const frame of frames) {
|
|
290
|
+
for (const line of frame.split('\n')) {
|
|
291
|
+
const trimmed = line.trim();
|
|
292
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
293
|
+
|
|
294
|
+
const jsonStr = trimmed.substring(6);
|
|
295
|
+
if (!jsonStr) continue;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const event = JSON.parse(jsonStr);
|
|
299
|
+
|
|
300
|
+
if (event.type === 'content' && event.body) {
|
|
301
|
+
// Content chunk — forward as OpenAI delta
|
|
302
|
+
originalWrite(
|
|
303
|
+
formatSSE(
|
|
304
|
+
toOpenAIStreamChunk({
|
|
305
|
+
id: completionId,
|
|
306
|
+
model: body.model,
|
|
307
|
+
delta: { content: String(event.body) },
|
|
308
|
+
}),
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
} else if (event.type === 'tool_call_chunks' && Array.isArray(event.body)) {
|
|
312
|
+
const chunks = toOpenAIToolCallChunks(event.body);
|
|
313
|
+
if (chunks.length) {
|
|
314
|
+
sawToolCalls = true;
|
|
315
|
+
originalWrite(
|
|
316
|
+
formatSSE(
|
|
317
|
+
toOpenAIStreamChunk({
|
|
318
|
+
id: completionId,
|
|
319
|
+
model: body.model,
|
|
320
|
+
delta: { tool_calls: chunks },
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
} else if (!sawToolCalls && event.type === 'tool_calls' && Array.isArray(event.body?.toolCalls)) {
|
|
326
|
+
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
327
|
+
if (chunks.length) {
|
|
328
|
+
sawToolCalls = true;
|
|
329
|
+
originalWrite(
|
|
330
|
+
formatSSE(
|
|
331
|
+
toOpenAIStreamChunk({
|
|
332
|
+
id: completionId,
|
|
333
|
+
model: body.model,
|
|
334
|
+
delta: { tool_calls: chunks },
|
|
335
|
+
}),
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
} else if (event.type === 'error' && event.body) {
|
|
340
|
+
// Error from the agent — surface as SSE error object
|
|
341
|
+
originalWrite(
|
|
342
|
+
formatSSE({
|
|
343
|
+
error: {
|
|
344
|
+
message: String(event.body),
|
|
345
|
+
type: 'server_error',
|
|
346
|
+
code: 'agent_error',
|
|
347
|
+
},
|
|
292
348
|
}),
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
message: String(event.body),
|
|
301
|
-
type: 'server_error',
|
|
302
|
-
code: 'agent_error',
|
|
303
|
-
},
|
|
304
|
-
}),
|
|
305
|
-
);
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
// stream_start, stream_end, tool_call_status, web_search,
|
|
352
|
+
// reasoning and new_message are NocoBase-only events and are ignored.
|
|
353
|
+
// These are NocoBase-internal events not part of the OpenAI protocol.
|
|
354
|
+
} catch {
|
|
355
|
+
// Non-JSON SSE line — ignore
|
|
306
356
|
}
|
|
307
|
-
// stream_start, stream_end, tool_calls, tool_call_status,
|
|
308
|
-
// web_search, reasoning, new_message, tool_call_chunks → silently ignored.
|
|
309
|
-
// These are NocoBase-internal events not part of the OpenAI protocol.
|
|
310
|
-
} catch {
|
|
311
|
-
// Non-JSON SSE line — ignore
|
|
312
357
|
}
|
|
313
358
|
}
|
|
314
359
|
return true;
|
|
@@ -322,7 +367,10 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
322
367
|
}),
|
|
323
368
|
);
|
|
324
369
|
|
|
325
|
-
await aiEmployee.stream({ userMessages });
|
|
370
|
+
streamSucceeded = await aiEmployee.stream({ userMessages });
|
|
371
|
+
if (!streamSucceeded) {
|
|
372
|
+
throw new Error('AI Employee stream failed');
|
|
373
|
+
}
|
|
326
374
|
try {
|
|
327
375
|
await lifecycle?.runAfterHooks(runtimeContext, { succeeded: true });
|
|
328
376
|
} finally {
|
|
@@ -332,20 +380,26 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
332
380
|
// Restore original write/end before sending our closing frames
|
|
333
381
|
(ctx.res as any).write = originalWrite;
|
|
334
382
|
(ctx.res as any).end = originalEnd;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
383
|
+
ctx.req.off('aborted', abortAgent);
|
|
384
|
+
ctx.res.off('close', abortAgent);
|
|
385
|
+
|
|
386
|
+
if (streamSucceeded && !ctx.res.destroyed) {
|
|
387
|
+
originalWrite(
|
|
388
|
+
formatSSE(
|
|
389
|
+
toOpenAIStreamChunk({
|
|
390
|
+
id: completionId,
|
|
391
|
+
model: body.model,
|
|
392
|
+
delta: {},
|
|
393
|
+
finishReason: 'stop',
|
|
394
|
+
}),
|
|
395
|
+
),
|
|
396
|
+
);
|
|
397
|
+
originalWrite(formatSSEDone());
|
|
398
|
+
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
399
|
+
} else {
|
|
400
|
+
ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'agent_error' };
|
|
401
|
+
}
|
|
402
|
+
if (!ctx.res.writableEnded && !ctx.res.destroyed) originalEnd();
|
|
349
403
|
}
|
|
350
404
|
} else {
|
|
351
405
|
// ── NON-STREAMING (invoke) ─────────────────────────────────────────────
|
|
@@ -422,6 +476,19 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
422
476
|
}
|
|
423
477
|
}
|
|
424
478
|
|
|
479
|
+
function toOpenAIToolCallChunks(value: unknown[]): OpenAIToolCallChunk[] {
|
|
480
|
+
return value.map((call: any, fallbackIndex) => ({
|
|
481
|
+
index: typeof call.index === 'number' ? call.index : fallbackIndex,
|
|
482
|
+
...(call.id ? { id: String(call.id), type: 'function' as const } : {}),
|
|
483
|
+
function: {
|
|
484
|
+
...(call.name ? { name: String(call.name) } : {}),
|
|
485
|
+
...(call.args !== undefined
|
|
486
|
+
? { arguments: typeof call.args === 'string' ? call.args : JSON.stringify(call.args) }
|
|
487
|
+
: {}),
|
|
488
|
+
},
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
|
|
425
492
|
/**
|
|
426
493
|
* Extract the last AI message content from a LangGraph invoke() result.
|
|
427
494
|
*
|