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
|
@@ -139,7 +139,13 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
139
139
|
if (method === 'POST' && subPath === '/chat/completions') {
|
|
140
140
|
const mode = await resolveMode(ctx);
|
|
141
141
|
await (mode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
|
|
142
|
-
logRequest(
|
|
142
|
+
logRequest(
|
|
143
|
+
ctx,
|
|
144
|
+
requestId,
|
|
145
|
+
model,
|
|
146
|
+
ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
|
|
147
|
+
Date.now() - t0,
|
|
148
|
+
);
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -169,7 +175,13 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
169
175
|
} else {
|
|
170
176
|
await handleCompletions(ctx, plugin);
|
|
171
177
|
}
|
|
172
|
-
logRequest(
|
|
178
|
+
logRequest(
|
|
179
|
+
ctx,
|
|
180
|
+
requestId,
|
|
181
|
+
model,
|
|
182
|
+
ctx.state.aiApiStreamResult?.succeeded === false ? 'error' : 'ok',
|
|
183
|
+
Date.now() - t0,
|
|
184
|
+
);
|
|
173
185
|
return;
|
|
174
186
|
}
|
|
175
187
|
|
|
@@ -219,7 +231,11 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
219
231
|
logRequest(ctx, requestId, model, 'error', Date.now() - t0);
|
|
220
232
|
if (!ctx.res.headersSent) {
|
|
221
233
|
ctx.status = 500;
|
|
222
|
-
ctx.body = toOpenAIError(
|
|
234
|
+
ctx.body = toOpenAIError(
|
|
235
|
+
500,
|
|
236
|
+
err instanceof Error && err.message ? err.message : 'Internal server error',
|
|
237
|
+
'server_error',
|
|
238
|
+
);
|
|
223
239
|
}
|
|
224
240
|
} finally {
|
|
225
241
|
if (usageId !== undefined) {
|
package/src/server/usage.ts
CHANGED
|
@@ -35,15 +35,18 @@ export async function startUsageRecord(
|
|
|
35
35
|
|
|
36
36
|
export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: number, status: 'succeeded' | 'failed') {
|
|
37
37
|
const response = (ctx.body || {}) as { usage?: Usage; id?: string; error?: { code?: string } };
|
|
38
|
-
const
|
|
38
|
+
const streamResult = ctx.state.aiApiStreamResult as
|
|
39
|
+
| { usage?: Usage; id?: string; errorCode?: string; succeeded: boolean }
|
|
40
|
+
| undefined;
|
|
41
|
+
const usage = response.usage || streamResult?.usage;
|
|
39
42
|
const values = {
|
|
40
|
-
status,
|
|
43
|
+
status: streamResult ? (streamResult.succeeded ? 'succeeded' : 'failed') : status,
|
|
41
44
|
httpStatus: ctx.status,
|
|
42
|
-
errorCode: response.error?.code,
|
|
45
|
+
errorCode: response.error?.code || streamResult?.errorCode,
|
|
43
46
|
inputTokens: usage?.prompt_tokens,
|
|
44
47
|
outputTokens: usage?.completion_tokens,
|
|
45
48
|
totalTokens: usage?.total_tokens,
|
|
46
|
-
providerRequestId: response.id,
|
|
49
|
+
providerRequestId: response.id || streamResult?.id,
|
|
47
50
|
completedAt: new Date(),
|
|
48
51
|
durationMs: Date.now() - startedAt,
|
|
49
52
|
responseMetadata: { usageSource: usage ? 'response' : 'unavailable' },
|
|
@@ -15,7 +15,7 @@ interface AIEmployeeConstructorOptions {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
interface AIEmployeeRuntime {
|
|
18
|
-
stream(options: { userMessages: unknown[] }): Promise<
|
|
18
|
+
stream(options: { userMessages: unknown[] }): Promise<boolean>;
|
|
19
19
|
invoke(options: { userMessages: unknown[] }): Promise<unknown>;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -1,142 +1,164 @@
|
|
|
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 crypto from 'crypto';
|
|
11
|
-
|
|
12
|
-
// ─── Model string parsing ───
|
|
13
|
-
|
|
14
|
-
export interface ParsedModel {
|
|
15
|
-
llmService: string;
|
|
16
|
-
modelId: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Parse OpenAI-style model string into NocoBase llmService + modelId.
|
|
21
|
-
* Format: "llmServiceName/modelId" (e.g. "my-openai/gpt-4o")
|
|
22
|
-
* If no "/" is present, the entire string is treated as modelId and llmService is empty.
|
|
23
|
-
*/
|
|
24
|
-
export function parseModelString(model: string): ParsedModel {
|
|
25
|
-
const slashIndex = model.indexOf('/');
|
|
26
|
-
if (slashIndex === -1) {
|
|
27
|
-
return { llmService: '', modelId: model };
|
|
28
|
-
}
|
|
29
|
-
return {
|
|
30
|
-
llmService: model.substring(0, slashIndex),
|
|
31
|
-
modelId: model.substring(slashIndex + 1),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ─── ID generation ───
|
|
36
|
-
|
|
37
|
-
export function generateCompletionId(): string {
|
|
38
|
-
return `chatcmpl-${crypto.randomBytes(16).toString('hex').substring(0, 29)}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ─── OpenAI error format ───
|
|
42
|
-
|
|
43
|
-
export function toOpenAIError(statusCode: number, message: string, type = 'invalid_request_error', code?: string) {
|
|
44
|
-
return {
|
|
45
|
-
error: {
|
|
46
|
-
message,
|
|
47
|
-
type,
|
|
48
|
-
param: null,
|
|
49
|
-
code: code || null,
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ─── OpenAI Chat Completion response (non-streaming) ───
|
|
55
|
-
|
|
56
|
-
export function toOpenAIResponse(options: {
|
|
57
|
-
id: string;
|
|
58
|
-
model: string;
|
|
59
|
-
content: string;
|
|
60
|
-
finishReason?: string;
|
|
61
|
-
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
id,
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 crypto from 'crypto';
|
|
11
|
+
|
|
12
|
+
// ─── Model string parsing ───
|
|
13
|
+
|
|
14
|
+
export interface ParsedModel {
|
|
15
|
+
llmService: string;
|
|
16
|
+
modelId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse OpenAI-style model string into NocoBase llmService + modelId.
|
|
21
|
+
* Format: "llmServiceName/modelId" (e.g. "my-openai/gpt-4o")
|
|
22
|
+
* If no "/" is present, the entire string is treated as modelId and llmService is empty.
|
|
23
|
+
*/
|
|
24
|
+
export function parseModelString(model: string): ParsedModel {
|
|
25
|
+
const slashIndex = model.indexOf('/');
|
|
26
|
+
if (slashIndex === -1) {
|
|
27
|
+
return { llmService: '', modelId: model };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
llmService: model.substring(0, slashIndex),
|
|
31
|
+
modelId: model.substring(slashIndex + 1),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── ID generation ───
|
|
36
|
+
|
|
37
|
+
export function generateCompletionId(): string {
|
|
38
|
+
return `chatcmpl-${crypto.randomBytes(16).toString('hex').substring(0, 29)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── OpenAI error format ───
|
|
42
|
+
|
|
43
|
+
export function toOpenAIError(statusCode: number, message: string, type = 'invalid_request_error', code?: string) {
|
|
44
|
+
return {
|
|
45
|
+
error: {
|
|
46
|
+
message,
|
|
47
|
+
type,
|
|
48
|
+
param: null,
|
|
49
|
+
code: code || null,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── OpenAI Chat Completion response (non-streaming) ───
|
|
55
|
+
|
|
56
|
+
export function toOpenAIResponse(options: {
|
|
57
|
+
id: string;
|
|
58
|
+
model: string;
|
|
59
|
+
content: string;
|
|
60
|
+
finishReason?: string;
|
|
61
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
62
|
+
toolCalls?: OpenAIToolCall[];
|
|
63
|
+
}) {
|
|
64
|
+
const {
|
|
65
|
+
id,
|
|
66
|
+
model,
|
|
67
|
+
content,
|
|
68
|
+
finishReason = options.toolCalls?.length ? 'tool_calls' : 'stop',
|
|
69
|
+
usage,
|
|
70
|
+
toolCalls,
|
|
71
|
+
} = options;
|
|
72
|
+
return {
|
|
73
|
+
id,
|
|
74
|
+
object: 'chat.completion',
|
|
75
|
+
created: Math.floor(Date.now() / 1000),
|
|
76
|
+
model,
|
|
77
|
+
system_fingerprint: null,
|
|
78
|
+
choices: [
|
|
79
|
+
{
|
|
80
|
+
index: 0,
|
|
81
|
+
message: {
|
|
82
|
+
role: 'assistant',
|
|
83
|
+
content,
|
|
84
|
+
...(toolCalls?.length ? { tool_calls: toolCalls } : {}),
|
|
85
|
+
},
|
|
86
|
+
logprobs: null,
|
|
87
|
+
finish_reason: finishReason,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── OpenAI Streaming chunk format ───
|
|
95
|
+
|
|
96
|
+
export function toOpenAIStreamChunk(options: {
|
|
97
|
+
id: string;
|
|
98
|
+
model: string;
|
|
99
|
+
delta: { role?: string; content?: string; tool_calls?: OpenAIToolCallChunk[] };
|
|
100
|
+
finishReason?: string | null;
|
|
101
|
+
}) {
|
|
102
|
+
const { id, model, delta, finishReason = null } = options;
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
object: 'chat.completion.chunk',
|
|
106
|
+
created: Math.floor(Date.now() / 1000),
|
|
107
|
+
model,
|
|
108
|
+
system_fingerprint: null,
|
|
109
|
+
choices: [
|
|
110
|
+
{
|
|
111
|
+
index: 0,
|
|
112
|
+
delta,
|
|
113
|
+
logprobs: null,
|
|
114
|
+
finish_reason: finishReason,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface OpenAIToolCall {
|
|
121
|
+
id: string;
|
|
122
|
+
type: 'function';
|
|
123
|
+
function: { name: string; arguments: string };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface OpenAIToolCallChunk {
|
|
127
|
+
index: number;
|
|
128
|
+
id?: string;
|
|
129
|
+
type?: 'function';
|
|
130
|
+
function?: { name?: string; arguments?: string };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── OpenAI Embeddings response ───
|
|
134
|
+
|
|
135
|
+
export function toOpenAIEmbeddingsResponse(options: { model: string; embeddings: number[][]; promptTokens?: number }) {
|
|
136
|
+
const { model, embeddings, promptTokens = 0 } = options;
|
|
137
|
+
return {
|
|
138
|
+
object: 'list' as const,
|
|
139
|
+
data: embeddings.map((embedding, index) => ({
|
|
140
|
+
object: 'embedding' as const,
|
|
141
|
+
embedding,
|
|
142
|
+
index,
|
|
143
|
+
})),
|
|
144
|
+
model,
|
|
145
|
+
usage: {
|
|
146
|
+
prompt_tokens: promptTokens,
|
|
147
|
+
total_tokens: promptTokens,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Format a streaming chunk as an SSE data line.
|
|
154
|
+
*/
|
|
155
|
+
export function formatSSE(data: any): string {
|
|
156
|
+
return `data: ${JSON.stringify(data)}\n\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Format the terminal SSE [DONE] signal.
|
|
161
|
+
*/
|
|
162
|
+
export function formatSSEDone(): string {
|
|
163
|
+
return `data: [DONE]\n\n`;
|
|
164
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
|
|
3
|
+
export function isStreamingRequested(value: unknown) {
|
|
4
|
+
return value !== false;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createRequestAbortController(ctx: Context) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const abort = () => {
|
|
10
|
+
if (!ctx.res.writableEnded) controller.abort(new Error('Client disconnected'));
|
|
11
|
+
};
|
|
12
|
+
ctx.req.once('aborted', abort);
|
|
13
|
+
ctx.res.once('close', abort);
|
|
14
|
+
return {
|
|
15
|
+
signal: controller.signal,
|
|
16
|
+
dispose() {
|
|
17
|
+
ctx.req.off('aborted', abort);
|
|
18
|
+
ctx.res.off('close', abort);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function writeResponse(ctx: Context, data: string) {
|
|
24
|
+
if (ctx.res.writableEnded || ctx.res.destroyed) return false;
|
|
25
|
+
if (!ctx.res.write(data)) await waitForDrain(ctx);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function waitForDrain(ctx: Context) {
|
|
30
|
+
return new Promise<void>((resolve, reject) => {
|
|
31
|
+
const cleanup = () => {
|
|
32
|
+
ctx.res.off('drain', onDrain);
|
|
33
|
+
ctx.res.off('close', onClose);
|
|
34
|
+
};
|
|
35
|
+
const onDrain = () => {
|
|
36
|
+
cleanup();
|
|
37
|
+
resolve();
|
|
38
|
+
};
|
|
39
|
+
const onClose = () => {
|
|
40
|
+
cleanup();
|
|
41
|
+
reject(new Error('Client disconnected'));
|
|
42
|
+
};
|
|
43
|
+
ctx.res.once('drain', onDrain);
|
|
44
|
+
ctx.res.once('close', onClose);
|
|
45
|
+
});
|
|
46
|
+
}
|