plugin-ai-api 1.0.12 → 1.0.14
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 +10 -9
- package/dist/server/collections/ai-api-usage-records.js +9 -1
- package/dist/server/migrations/20260727140000-change-usage-user-id-to-bigint.js +208 -0
- package/dist/server/routes/agent-completions.js +4 -0
- package/dist/server/routes/auth.js +1 -0
- package/dist/server/routes/chat-completions.js +13 -13
- package/dist/server/routes/completions.js +13 -13
- package/dist/server/routes/embeddings.js +3 -1
- package/dist/server/routes/router.js +26 -17
- package/dist/server/usage.js +81 -14
- package/dist/server/utils/openai-format.js +2 -2
- package/package.json +1 -1
- package/src/server/__tests__/usage-migration.test.ts +171 -0
- package/src/server/__tests__/usage-route.test.ts +105 -0
- package/src/server/__tests__/usage.test.ts +201 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -1
- package/src/server/migrations/20260727140000-change-usage-user-id-to-bigint.ts +222 -0
- package/src/server/routes/agent-completions.ts +4 -0
- package/src/server/routes/auth.ts +1 -0
- package/src/server/routes/chat-completions.ts +14 -16
- package/src/server/routes/completions.ts +14 -16
- package/src/server/routes/embeddings.ts +193 -191
- package/src/server/routes/router.ts +31 -16
- package/src/server/usage.ts +141 -17
- package/src/server/utils/openai-format.ts +13 -5
|
@@ -1,191 +1,193 @@
|
|
|
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, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
|
|
12
|
-
import { resolveModelString } from '../utils/resolve-service';
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* - openai
|
|
23
|
-
* -
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* -
|
|
32
|
-
* - Token
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
ctx.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ctx.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ctx.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
ctx.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
ctx.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
ctx.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
ctx.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
'
|
|
96
|
-
'
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
ctx.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'
|
|
109
|
-
'
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
ctx.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
ctx.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
ctx.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
ctx.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
'
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
ctx.
|
|
178
|
-
ctx.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
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, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
|
|
12
|
+
import { resolveModelString } from '../utils/resolve-service';
|
|
13
|
+
import { setAiApiUsageUnavailable } from '../usage';
|
|
14
|
+
import type PluginAiApiServer from '../plugin';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* POST /api/ai-llm/v1/embeddings
|
|
18
|
+
*
|
|
19
|
+
* OpenAI-compatible embeddings endpoint.
|
|
20
|
+
*
|
|
21
|
+
* Supported providers (those with an `embedding` field in LLMProviderMeta):
|
|
22
|
+
* - openai → OpenAiEmbeddingProvider
|
|
23
|
+
* - openai-completions → OpenAiEmbeddingProvider
|
|
24
|
+
* - dashscope → DashscopeEmbeddingProvider
|
|
25
|
+
* - google-genai → GoogleGenAIEmbeddingProvider
|
|
26
|
+
* - ollama → OllamaEmbeddingProvider
|
|
27
|
+
*
|
|
28
|
+
* Not supported: anthropic, deepseek, kimi (no embedding provider registered).
|
|
29
|
+
*
|
|
30
|
+
* Limitations:
|
|
31
|
+
* - encoding_format 'base64' is not supported (always returns float arrays)
|
|
32
|
+
* - Token counts always return 0 (LangChain embeddings API doesn't expose this)
|
|
33
|
+
* - Token array input (integer[]) is not supported, only string input
|
|
34
|
+
*/
|
|
35
|
+
export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer) {
|
|
36
|
+
const body = ctx.request.body as any;
|
|
37
|
+
|
|
38
|
+
// ─── Validate ────────────────────────────────────────────────────────────
|
|
39
|
+
if (!body?.model) {
|
|
40
|
+
ctx.status = 400;
|
|
41
|
+
ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (body.input === undefined || body.input === null) {
|
|
46
|
+
ctx.status = 400;
|
|
47
|
+
ctx.body = toOpenAIError(400, "'input' is required", 'invalid_request_error', 'missing_input');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (body.encoding_format === 'base64') {
|
|
52
|
+
ctx.status = 400;
|
|
53
|
+
ctx.body = toOpenAIError(
|
|
54
|
+
400,
|
|
55
|
+
"encoding_format 'base64' is not supported. Use 'float' (default) or omit the parameter.",
|
|
56
|
+
'invalid_request_error',
|
|
57
|
+
'unsupported_encoding_format',
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Normalize input to string[] ─────────────────────────────────────────
|
|
63
|
+
let inputs: string[];
|
|
64
|
+
if (typeof body.input === 'string') {
|
|
65
|
+
inputs = [body.input];
|
|
66
|
+
} else if (Array.isArray(body.input)) {
|
|
67
|
+
if (body.input.length === 0) {
|
|
68
|
+
ctx.status = 400;
|
|
69
|
+
ctx.body = toOpenAIError(400, "'input' array must not be empty", 'invalid_request_error');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (typeof body.input[0] === 'number') {
|
|
73
|
+
ctx.status = 400;
|
|
74
|
+
ctx.body = toOpenAIError(
|
|
75
|
+
400,
|
|
76
|
+
'Token array input is not supported. Please provide string input.',
|
|
77
|
+
'invalid_request_error',
|
|
78
|
+
'unsupported_input_type',
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
inputs = body.input as string[];
|
|
83
|
+
} else {
|
|
84
|
+
ctx.status = 400;
|
|
85
|
+
ctx.body = toOpenAIError(400, "'input' must be a string or array of strings", 'invalid_request_error');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Resolve model ────────────────────────────────────────────────────────
|
|
90
|
+
const resolved = await resolveModelString(ctx, body.model);
|
|
91
|
+
if (!resolved) {
|
|
92
|
+
ctx.status = 404;
|
|
93
|
+
ctx.body = toOpenAIError(
|
|
94
|
+
404,
|
|
95
|
+
`Could not resolve model '${body.model}'. Use GET /v1/models to list available models.`,
|
|
96
|
+
'invalid_request_error',
|
|
97
|
+
'model_not_found',
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { service, modelId } = resolved;
|
|
103
|
+
|
|
104
|
+
if (service.enabled === false) {
|
|
105
|
+
ctx.status = 404;
|
|
106
|
+
ctx.body = toOpenAIError(
|
|
107
|
+
404,
|
|
108
|
+
`LLM service '${service.title || service.name}' is disabled`,
|
|
109
|
+
'invalid_request_error',
|
|
110
|
+
'model_not_found',
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Check service whitelist ──────────────────────────────────────────────
|
|
116
|
+
try {
|
|
117
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
118
|
+
if (config?.enabledLlmServices?.length) {
|
|
119
|
+
const allowed = config.enabledLlmServices.some((s: string) => s === service.name || s === service.title);
|
|
120
|
+
if (!allowed) {
|
|
121
|
+
ctx.status = 403;
|
|
122
|
+
ctx.body = toOpenAIError(
|
|
123
|
+
403,
|
|
124
|
+
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
125
|
+
'invalid_request_error',
|
|
126
|
+
'model_not_available',
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Config read failure: fail open
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Get embedding provider ───────────────────────────────────────────────
|
|
136
|
+
const aiPlugin = ctx.app.pm.get('ai') as any;
|
|
137
|
+
if (!aiPlugin) {
|
|
138
|
+
ctx.status = 500;
|
|
139
|
+
ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
144
|
+
if (!providerMeta) {
|
|
145
|
+
ctx.status = 500;
|
|
146
|
+
ctx.body = toOpenAIError(500, `Provider '${service.provider}' not registered`, 'server_error');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// providerMeta.embedding is the EmbeddingProvider constructor (if supported by this provider)
|
|
151
|
+
if (!providerMeta.embedding) {
|
|
152
|
+
ctx.status = 400;
|
|
153
|
+
ctx.body = toOpenAIError(
|
|
154
|
+
400,
|
|
155
|
+
`Provider '${providerMeta.title || service.provider}' does not support embeddings. ` +
|
|
156
|
+
`Embedding-capable providers: openai, openai-completions, dashscope, google-genai, ollama.`,
|
|
157
|
+
'invalid_request_error',
|
|
158
|
+
'model_not_supported',
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// ─── Instantiate and call the embedding provider ──────────────────────
|
|
165
|
+
const EmbeddingClass = providerMeta.embedding;
|
|
166
|
+
const embeddingProvider = new EmbeddingClass({
|
|
167
|
+
app: ctx.app,
|
|
168
|
+
serviceOptions: service.options, // Contains apiKey, baseURL, etc.
|
|
169
|
+
modelOptions: { model: modelId }, // The specific embedding model
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// createEmbedding() returns a LangChain EmbeddingsInterface.
|
|
173
|
+
// embedDocuments() accepts string[] and returns number[][] (one vector per input).
|
|
174
|
+
const embeddingModel = embeddingProvider.createEmbedding();
|
|
175
|
+
const vectors: number[][] = await embeddingModel.embedDocuments(inputs);
|
|
176
|
+
|
|
177
|
+
ctx.status = 200;
|
|
178
|
+
ctx.set('Content-Type', 'application/json');
|
|
179
|
+
setAiApiUsageUnavailable(ctx);
|
|
180
|
+
ctx.body = toOpenAIEmbeddingsResponse({
|
|
181
|
+
model: body.model,
|
|
182
|
+
embeddings: vectors,
|
|
183
|
+
// LangChain's EmbeddingsInterface does not expose token counts.
|
|
184
|
+
promptTokens: null,
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
ctx.log.error('AI API embeddings error:', err);
|
|
188
|
+
if (!ctx.res.headersSent) {
|
|
189
|
+
ctx.status = 500;
|
|
190
|
+
ctx.body = toOpenAIError(500, err.message || 'Failed to generate embeddings', 'server_error');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -19,10 +19,13 @@ import { toOpenAIError } from '../utils/openai-format';
|
|
|
19
19
|
import { createRateLimitMiddleware } from '../middleware/rate-limit';
|
|
20
20
|
import { checkRolePermission } from '../middleware/role-permission';
|
|
21
21
|
import { startUsageRecord, finishUsageRecord } from '../usage';
|
|
22
|
+
import { isStreamingRequested } from '../utils/streaming';
|
|
22
23
|
import type PluginAiApiServer from '../plugin';
|
|
23
24
|
|
|
24
25
|
const API_PREFIX = '/api/ai-llm/v1';
|
|
25
26
|
|
|
27
|
+
type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
|
|
28
|
+
|
|
26
29
|
/**
|
|
27
30
|
* Main Koa middleware router for OpenAI-compatible endpoints.
|
|
28
31
|
*
|
|
@@ -59,7 +62,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
59
62
|
|
|
60
63
|
// Prevent NocoBase's dataWrapping middleware from wrapping OpenAI-format responses
|
|
61
64
|
// in an extra {"data": ...} envelope, which breaks OpenAI-compatible clients like n8n.
|
|
62
|
-
(ctx as
|
|
65
|
+
(ctx as DataWrappingContext).withoutDataWrapping = true;
|
|
63
66
|
|
|
64
67
|
// Parse the sub-path after prefix
|
|
65
68
|
const subPath = path.substring(API_PREFIX.length);
|
|
@@ -88,8 +91,9 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
88
91
|
try {
|
|
89
92
|
const rawBody = await getRawBody(ctx);
|
|
90
93
|
ctx.request.body = JSON.parse(rawBody);
|
|
91
|
-
} catch (bodyErr:
|
|
92
|
-
const status =
|
|
94
|
+
} catch (bodyErr: unknown) {
|
|
95
|
+
const status =
|
|
96
|
+
bodyErr && typeof bodyErr === 'object' && 'statusCode' in bodyErr && bodyErr.statusCode === 413 ? 413 : 400;
|
|
93
97
|
const message = status === 413 ? 'Request body too large (max 10 MB)' : 'Invalid JSON in request body';
|
|
94
98
|
ctx.status = status;
|
|
95
99
|
ctx.body = toOpenAIError(status, message, 'invalid_request_error');
|
|
@@ -119,17 +123,29 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
// ─── Route matching ───────────────────────────────────────────────────
|
|
122
|
-
const
|
|
126
|
+
const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
|
|
127
|
+
const model = requestBody.model === undefined || requestBody.model === null ? '-' : String(requestBody.model);
|
|
128
|
+
const isUsageEndpoint =
|
|
129
|
+
method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions' || subPath === '/embeddings');
|
|
130
|
+
const isStreamingEndpoint = method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions');
|
|
131
|
+
const resolvedMode = isUsageEndpoint ? await resolveMode(ctx) : 'llm';
|
|
132
|
+
const streaming = isStreamingEndpoint && isStreamingRequested(requestBody.stream);
|
|
133
|
+
if (streaming) {
|
|
134
|
+
const streamOptions = requestBody.stream_options;
|
|
135
|
+
ctx.request.body = {
|
|
136
|
+
...requestBody,
|
|
137
|
+
stream_options: {
|
|
138
|
+
...(streamOptions && typeof streamOptions === 'object' ? streamOptions : {}),
|
|
139
|
+
include_usage: true,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
123
143
|
const t0 = Date.now();
|
|
124
144
|
let usageId: unknown;
|
|
125
145
|
try {
|
|
126
|
-
usageId =
|
|
127
|
-
ctx,
|
|
128
|
-
|
|
129
|
-
subPath,
|
|
130
|
-
String(model),
|
|
131
|
-
Boolean((ctx.request.body as any)?.stream),
|
|
132
|
-
);
|
|
146
|
+
usageId = isUsageEndpoint
|
|
147
|
+
? await startUsageRecord(ctx, requestId, subPath, model, streaming, resolvedMode)
|
|
148
|
+
: undefined;
|
|
133
149
|
} catch (usageError) {
|
|
134
150
|
ctx.log.error('AI API usage record could not be created:', usageError);
|
|
135
151
|
}
|
|
@@ -137,8 +153,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
137
153
|
try {
|
|
138
154
|
// POST /v1/chat/completions — route based on mode
|
|
139
155
|
if (method === 'POST' && subPath === '/chat/completions') {
|
|
140
|
-
|
|
141
|
-
await (mode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
|
|
156
|
+
await (resolvedMode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
|
|
142
157
|
logRequest(
|
|
143
158
|
ctx,
|
|
144
159
|
requestId,
|
|
@@ -158,11 +173,11 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
158
173
|
|
|
159
174
|
// POST /v1/completions (legacy text completions — used by LiteLLM)
|
|
160
175
|
if (method === 'POST' && subPath === '/completions') {
|
|
161
|
-
const completionsMode =
|
|
176
|
+
const completionsMode = resolvedMode;
|
|
162
177
|
if (completionsMode === 'agent') {
|
|
163
178
|
// Convert legacy prompt → messages format for agent handler
|
|
164
|
-
const reqBody = ctx.request.body as
|
|
165
|
-
if (reqBody
|
|
179
|
+
const reqBody = (ctx.request.body || {}) as Record<string, unknown>;
|
|
180
|
+
if (reqBody.prompt !== undefined) {
|
|
166
181
|
const prompt =
|
|
167
182
|
typeof reqBody.prompt === 'string'
|
|
168
183
|
? reqBody.prompt
|