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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Context } from '@nocobase/actions';
|
|
2
|
+
|
|
3
|
+
type Usage = { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
4
|
+
|
|
5
|
+
export async function startUsageRecord(
|
|
6
|
+
ctx: Context,
|
|
7
|
+
requestId: string,
|
|
8
|
+
endpoint: string,
|
|
9
|
+
model: string,
|
|
10
|
+
streaming: boolean,
|
|
11
|
+
) {
|
|
12
|
+
const body = (ctx.request.body || {}) as Record<string, unknown>;
|
|
13
|
+
const messages = Array.isArray(body.messages) ? body.messages : undefined;
|
|
14
|
+
const oauth = ctx.state.oauthPrincipal as { clientId?: string; subject?: string; scopes?: string[] } | undefined;
|
|
15
|
+
const record = await ctx.db.getRepository('aiApiUsageRecords').create({
|
|
16
|
+
values: {
|
|
17
|
+
requestId,
|
|
18
|
+
userId: String(ctx.state.currentUser?.id),
|
|
19
|
+
roleName: ctx.state.currentRole || ctx.state.currentRoles?.[0] || 'unknown',
|
|
20
|
+
authType: ctx.state.aiApiAuthType || (oauth ? 'oidc' : 'session'),
|
|
21
|
+
oauthClientId: oauth?.clientId,
|
|
22
|
+
oauthSubject: oauth?.subject,
|
|
23
|
+
oauthScopes: oauth?.scopes,
|
|
24
|
+
endpoint,
|
|
25
|
+
mode: ctx.get('X-AI-Mode') || undefined,
|
|
26
|
+
model: model === '-' ? undefined : model,
|
|
27
|
+
status: 'pending',
|
|
28
|
+
streaming,
|
|
29
|
+
startedAt: new Date(),
|
|
30
|
+
requestMetadata: { messageCount: messages?.length, requestedMaxTokens: body.max_tokens },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
return record.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: number, status: 'succeeded' | 'failed') {
|
|
37
|
+
const response = (ctx.body || {}) as { usage?: Usage; id?: string; error?: { code?: string } };
|
|
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;
|
|
42
|
+
const values = {
|
|
43
|
+
status: streamResult ? (streamResult.succeeded ? 'succeeded' : 'failed') : status,
|
|
44
|
+
httpStatus: ctx.status,
|
|
45
|
+
errorCode: response.error?.code || streamResult?.errorCode,
|
|
46
|
+
inputTokens: usage?.prompt_tokens,
|
|
47
|
+
outputTokens: usage?.completion_tokens,
|
|
48
|
+
totalTokens: usage?.total_tokens,
|
|
49
|
+
providerRequestId: response.id || streamResult?.id,
|
|
50
|
+
completedAt: new Date(),
|
|
51
|
+
durationMs: Date.now() - startedAt,
|
|
52
|
+
responseMetadata: { usageSource: usage ? 'response' : 'unavailable' },
|
|
53
|
+
};
|
|
54
|
+
await ctx.db.getRepository('aiApiUsageRecords').update({ filterByTk: id, values });
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|