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,66 +1,79 @@
|
|
|
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 { toOpenAIError } from '../utils/openai-format';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 { toOpenAIError } from '../utils/openai-format';
|
|
12
|
+
|
|
13
|
+
const PERMISSION_TTL_MS = 15_000;
|
|
14
|
+
const permissionCache = new Map<string, { record: any; expiresAt: number }>();
|
|
15
|
+
|
|
16
|
+
export function invalidateRolePermissionCache(roleName?: string): void {
|
|
17
|
+
if (roleName) permissionCache.delete(roleName);
|
|
18
|
+
else permissionCache.clear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether the authenticated role is allowed to use the AI API.
|
|
23
|
+
* Loads the permission record and stores it in ctx.state.aiApiRolePermission.
|
|
24
|
+
*
|
|
25
|
+
* Returns true if access is allowed (caller may proceed).
|
|
26
|
+
* Returns false if access is denied (403 already written to ctx, caller must return).
|
|
27
|
+
*
|
|
28
|
+
* The 'root' and 'admin' roles always bypass the check.
|
|
29
|
+
*/
|
|
30
|
+
export async function checkRolePermission(ctx: Context): Promise<boolean> {
|
|
31
|
+
const roleName = ctx.state.currentRoles?.[0] || 'member';
|
|
32
|
+
|
|
33
|
+
// root / admin always allowed
|
|
34
|
+
if (roleName === 'root' || roleName === 'admin') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cached = permissionCache.get(roleName);
|
|
39
|
+
const record =
|
|
40
|
+
cached && cached.expiresAt > Date.now()
|
|
41
|
+
? cached.record
|
|
42
|
+
: await ctx.db.getRepository('aiApiRolePermissions').findOne({ filter: { roleName } });
|
|
43
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
44
|
+
permissionCache.set(roleName, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!record?.enabled) {
|
|
48
|
+
ctx.status = 403;
|
|
49
|
+
ctx.body = toOpenAIError(
|
|
50
|
+
403,
|
|
51
|
+
`Role '${roleName}' is not authorized to use the AI API. ` +
|
|
52
|
+
`An admin must enable access in Settings → Users & Permissions → [Role] → AI API.`,
|
|
53
|
+
'permission_denied',
|
|
54
|
+
'role_not_permitted',
|
|
55
|
+
);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Store for downstream handlers
|
|
60
|
+
ctx.state.aiApiRolePermission = record;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check whether the current role is allowed to use a specific AI Employee.
|
|
66
|
+
* Must be called after checkRolePermission (so ctx.state.aiApiRolePermission is set).
|
|
67
|
+
*
|
|
68
|
+
* Returns true when:
|
|
69
|
+
* - Role is admin/root (no permission record stored)
|
|
70
|
+
* - allowAllEmployees is true
|
|
71
|
+
* - The employeeUsername is in the allowedEmployees list
|
|
72
|
+
*/
|
|
73
|
+
export function checkEmployeeAccess(ctx: Context, employeeUsername: string): boolean {
|
|
74
|
+
const perm = ctx.state.aiApiRolePermission;
|
|
75
|
+
// admin/root paths have no record stored → always allowed
|
|
76
|
+
if (!perm) return true;
|
|
77
|
+
if (perm.allowAllEmployees) return true;
|
|
78
|
+
return ((perm.allowedEmployees as string[]) || []).includes(employeeUsername);
|
|
79
|
+
}
|
package/src/server/plugin.ts
CHANGED
|
@@ -1,89 +1,99 @@
|
|
|
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 { Plugin } from '@nocobase/server';
|
|
11
|
-
import { createAiLlmRouter } from './routes/router';
|
|
12
|
-
import aiApiConfigResource from './resource/ai-api-config';
|
|
13
|
-
import { RateLimiter } from './utils/rate-limiter';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
(dayjsLib as any).extend(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
this.app.
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
this.app.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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 { Plugin } from '@nocobase/server';
|
|
11
|
+
import { createAiLlmRouter } from './routes/router';
|
|
12
|
+
import aiApiConfigResource from './resource/ai-api-config';
|
|
13
|
+
import { RateLimiter } from './utils/rate-limiter';
|
|
14
|
+
import { invalidateRolePermissionCache } from './middleware/role-permission';
|
|
15
|
+
|
|
16
|
+
// Ensure dayjs timezone + utc plugins are loaded.
|
|
17
|
+
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
18
|
+
// extend the 'timezone' plugin, causing utcOffset(value) to behave as a
|
|
19
|
+
// getter (returns a number) instead of a setter (returns a dayjs instance).
|
|
20
|
+
// That breaks parse-filter.js's utc2unit() → "m.startOf is not a function".
|
|
21
|
+
// Extending here patches the shared CommonJS dayjs module instance for the
|
|
22
|
+
// entire Node.js process before any AIEmployee call is made.
|
|
23
|
+
import dayjsLib from 'dayjs';
|
|
24
|
+
import utcPlugin from 'dayjs/plugin/utc';
|
|
25
|
+
import timezonePlugin from 'dayjs/plugin/timezone';
|
|
26
|
+
(dayjsLib as any).extend(utcPlugin);
|
|
27
|
+
(dayjsLib as any).extend(timezonePlugin);
|
|
28
|
+
|
|
29
|
+
export class PluginAiApiServer extends Plugin {
|
|
30
|
+
/**
|
|
31
|
+
* Singleton rate limiter — lives for the entire plugin lifetime, shared across all requests.
|
|
32
|
+
* Uses a 1-minute sliding window to enforce rateLimitPerMinute from aiApiConfig.
|
|
33
|
+
*/
|
|
34
|
+
rateLimiter = new RateLimiter(60_000);
|
|
35
|
+
|
|
36
|
+
private gcInterval: NodeJS.Timeout | null = null;
|
|
37
|
+
|
|
38
|
+
async afterAdd() {}
|
|
39
|
+
|
|
40
|
+
async beforeLoad() {}
|
|
41
|
+
|
|
42
|
+
async load() {
|
|
43
|
+
// 1. Register raw Koa middleware for OpenAI-compatible endpoints
|
|
44
|
+
// Must run before 'resourcer' so URL paths match OpenAI convention
|
|
45
|
+
// OIDC access tokens must first pass through plugin-idp-oauth, which validates
|
|
46
|
+
// issuer/audience/scope and rewrites them to a NocoBase internal token.
|
|
47
|
+
this.app.use(createAiLlmRouter(this), { after: 'idp-oauth-resource-auth', before: 'resourcer' });
|
|
48
|
+
|
|
49
|
+
// 2. Register admin config resource
|
|
50
|
+
this.app.resourceManager.define(aiApiConfigResource);
|
|
51
|
+
|
|
52
|
+
this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
|
|
53
|
+
invalidateRolePermissionCache(model.get('roleName'));
|
|
54
|
+
});
|
|
55
|
+
this.app.db.on('aiApiRolePermissions.afterDestroy', (model) => {
|
|
56
|
+
invalidateRolePermissionCache(model.get('roleName'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// 3. Set ACL permissions for admin config + role permissions management
|
|
60
|
+
this.app.acl.registerSnippet({
|
|
61
|
+
name: `pm.${this.name}.configuration`,
|
|
62
|
+
actions: ['aiApiConfig:*', 'aiApiRolePermissions:*'],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// 4. GC the rate limiter every 5 minutes to evict stale user entries.
|
|
66
|
+
// .unref() prevents this timer from keeping the process alive on shutdown.
|
|
67
|
+
this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
|
|
68
|
+
this.gcInterval.unref();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async install() {
|
|
72
|
+
// Create default config record on first install
|
|
73
|
+
const existing = await this.db.getRepository('aiApiConfig').findOne();
|
|
74
|
+
if (!existing) {
|
|
75
|
+
await this.db.getRepository('aiApiConfig').create({
|
|
76
|
+
values: {
|
|
77
|
+
defaultAiEmployee: '',
|
|
78
|
+
enabledLlmServices: [],
|
|
79
|
+
rateLimitPerMinute: 60,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async afterEnable() {}
|
|
86
|
+
|
|
87
|
+
async afterDisable() {}
|
|
88
|
+
|
|
89
|
+
async remove() {
|
|
90
|
+
// Clean up the GC timer so we don't leak resources during hot-reload
|
|
91
|
+
if (this.gcInterval) {
|
|
92
|
+
clearInterval(this.gcInterval);
|
|
93
|
+
this.gcInterval = null;
|
|
94
|
+
}
|
|
95
|
+
this.rateLimiter.clear();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default PluginAiApiServer;
|
|
@@ -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
|
*
|