plugin-ai-api 1.0.0
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/README.md +1 -0
- package/client.d.ts +2 -0
- package/client.js +1 -0
- package/dist/client/824c2f7487ee05fd.js +1 -0
- package/dist/client/AiApiConfigPage.d.ts +3 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/locale.d.ts +10 -0
- package/dist/client/models/index.d.ts +10 -0
- package/dist/client/plugin.d.ts +6 -0
- package/dist/externalVersion.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +39 -0
- package/dist/locale/en-US.json +10 -0
- package/dist/locale/zh-CN.json +10 -0
- package/dist/server/collections/ai-api-config.d.ts +2 -0
- package/dist/server/collections/ai-api-config.js +63 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +33 -0
- package/dist/server/middleware/rate-limit.d.ts +18 -0
- package/dist/server/middleware/rate-limit.js +61 -0
- package/dist/server/plugin.d.ts +18 -0
- package/dist/server/plugin.js +87 -0
- package/dist/server/resource/ai-api-config.d.ts +10 -0
- package/dist/server/resource/ai-api-config.js +73 -0
- package/dist/server/routes/agent-completions.d.ts +34 -0
- package/dist/server/routes/agent-completions.js +300 -0
- package/dist/server/routes/auth.d.ts +8 -0
- package/dist/server/routes/auth.js +110 -0
- package/dist/server/routes/chat-completions.d.ts +9 -0
- package/dist/server/routes/chat-completions.js +237 -0
- package/dist/server/routes/completions.d.ts +10 -0
- package/dist/server/routes/completions.js +245 -0
- package/dist/server/routes/embeddings.d.ts +22 -0
- package/dist/server/routes/embeddings.js +166 -0
- package/dist/server/routes/models.d.ts +21 -0
- package/dist/server/routes/models.js +154 -0
- package/dist/server/routes/router.d.ts +25 -0
- package/dist/server/routes/router.js +189 -0
- package/dist/server/utils/openai-format.d.ts +99 -0
- package/dist/server/utils/openai-format.js +140 -0
- package/dist/server/utils/rate-limiter.d.ts +35 -0
- package/dist/server/utils/rate-limiter.js +81 -0
- package/dist/server/utils/resolve-service.d.ts +19 -0
- package/dist/server/utils/resolve-service.js +71 -0
- package/nocobase-plugin-ai-api-2.0.20.tgz +0 -0
- package/package.json +13 -0
- package/server.d.ts +2 -0
- package/server.js +1 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
var completions_exports = {};
|
|
19
|
+
__export(completions_exports, {
|
|
20
|
+
handleCompletions: () => handleCompletions
|
|
21
|
+
});
|
|
22
|
+
module.exports = __toCommonJS(completions_exports);
|
|
23
|
+
var import_openai_format = require("../utils/openai-format");
|
|
24
|
+
var import_resolve_service = require("../utils/resolve-service");
|
|
25
|
+
async function handleCompletions(ctx, plugin) {
|
|
26
|
+
var _a;
|
|
27
|
+
const body = ctx.request.body;
|
|
28
|
+
if (!(body == null ? void 0 : body.model)) {
|
|
29
|
+
ctx.status = 400;
|
|
30
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(400, "'model' is required", "invalid_request_error", "missing_model");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!(body == null ? void 0 : body.prompt) && body.prompt !== "") {
|
|
34
|
+
ctx.status = 400;
|
|
35
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(400, "'prompt' is required", "invalid_request_error", "missing_prompt");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (body.n !== void 0 && body.n !== null && body.n !== 1) {
|
|
39
|
+
ctx.status = 400;
|
|
40
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
41
|
+
400,
|
|
42
|
+
`The 'n' parameter value ${body.n} is not supported. This API gateway always returns exactly one completion (n=1). Please omit 'n' or set it to 1.`,
|
|
43
|
+
"invalid_request_error",
|
|
44
|
+
"unsupported_parameter"
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const stream = body.stream === true;
|
|
49
|
+
const resolved = await (0, import_resolve_service.resolveModelString)(ctx, body.model);
|
|
50
|
+
if (!resolved) {
|
|
51
|
+
ctx.status = 404;
|
|
52
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
53
|
+
404,
|
|
54
|
+
`Could not resolve model '${body.model}'. Format: 'serviceName/modelId'. Use GET /v1/models to see available models.`,
|
|
55
|
+
"invalid_request_error",
|
|
56
|
+
"model_not_found"
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const { service, modelId } = resolved;
|
|
61
|
+
try {
|
|
62
|
+
const aiPlugin = ctx.app.pm.get("ai");
|
|
63
|
+
if (!aiPlugin) {
|
|
64
|
+
ctx.status = 500;
|
|
65
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, "AI plugin not available", "server_error");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (service.enabled === false) {
|
|
69
|
+
ctx.status = 404;
|
|
70
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(404, `LLM service '${service.title || service.name}' is disabled`, "invalid_request_error", "model_not_found");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
74
|
+
if ((_a = config == null ? void 0 : config.enabledLlmServices) == null ? void 0 : _a.length) {
|
|
75
|
+
const serviceName = service.name;
|
|
76
|
+
const serviceTitle = service.title;
|
|
77
|
+
const isAllowed = config.enabledLlmServices.some(
|
|
78
|
+
(s) => s === serviceName || s === serviceTitle
|
|
79
|
+
);
|
|
80
|
+
if (!isAllowed) {
|
|
81
|
+
ctx.status = 403;
|
|
82
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
83
|
+
403,
|
|
84
|
+
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
85
|
+
"invalid_request_error",
|
|
86
|
+
"model_not_available"
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
92
|
+
if (!providerMeta) {
|
|
93
|
+
ctx.status = 500;
|
|
94
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const modelOptions = {
|
|
98
|
+
model: modelId,
|
|
99
|
+
llmService: service.name
|
|
100
|
+
};
|
|
101
|
+
if (body.temperature !== void 0) modelOptions.temperature = body.temperature;
|
|
102
|
+
if (body.top_p !== void 0) modelOptions.topP = body.top_p;
|
|
103
|
+
if (body.max_tokens !== void 0) modelOptions.maxTokens = body.max_tokens;
|
|
104
|
+
if (body.stop !== void 0) modelOptions.stop = body.stop;
|
|
105
|
+
const Provider = providerMeta.provider;
|
|
106
|
+
const provider = new Provider({
|
|
107
|
+
app: ctx.app,
|
|
108
|
+
serviceOptions: service.options,
|
|
109
|
+
modelOptions
|
|
110
|
+
});
|
|
111
|
+
const prompt = typeof body.prompt === "string" ? body.prompt : Array.isArray(body.prompt) ? body.prompt.join("\n") : String(body.prompt);
|
|
112
|
+
const langchainMessages = [];
|
|
113
|
+
if (config == null ? void 0 : config.defaultAiEmployee) {
|
|
114
|
+
const employee = await ctx.db.getRepository("aiEmployees").findOne({
|
|
115
|
+
filter: { username: config.defaultAiEmployee }
|
|
116
|
+
});
|
|
117
|
+
if (employee) {
|
|
118
|
+
const systemPrompt = employee.about || employee.defaultPrompt || "";
|
|
119
|
+
if (systemPrompt) {
|
|
120
|
+
langchainMessages.push(["system", systemPrompt]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
langchainMessages.push(["human", prompt]);
|
|
125
|
+
const completionId = (0, import_openai_format.generateCompletionId)().replace("chatcmpl-", "cmpl-");
|
|
126
|
+
const chatModel = provider.createModel();
|
|
127
|
+
if (stream) {
|
|
128
|
+
await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
129
|
+
} else {
|
|
130
|
+
await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
ctx.log.error("AI API completions error:", err);
|
|
134
|
+
if (!ctx.res.headersSent) {
|
|
135
|
+
ctx.status = 500;
|
|
136
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, err.message || "Internal server error", "server_error");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function handleNonStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
141
|
+
const result = await chatModel.invoke(messages);
|
|
142
|
+
let text = "";
|
|
143
|
+
if (typeof result.content === "string") {
|
|
144
|
+
text = result.content;
|
|
145
|
+
} else if (Array.isArray(result.content)) {
|
|
146
|
+
const textPart = result.content.find((c) => c.type === "text");
|
|
147
|
+
text = (textPart == null ? void 0 : textPart.text) || JSON.stringify(result.content);
|
|
148
|
+
}
|
|
149
|
+
const usage = result.usage_metadata ? {
|
|
150
|
+
prompt_tokens: result.usage_metadata.input_tokens || 0,
|
|
151
|
+
completion_tokens: result.usage_metadata.output_tokens || 0,
|
|
152
|
+
total_tokens: result.usage_metadata.total_tokens || 0
|
|
153
|
+
} : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
154
|
+
ctx.status = 200;
|
|
155
|
+
ctx.body = {
|
|
156
|
+
id: completionId,
|
|
157
|
+
object: "text_completion",
|
|
158
|
+
created: Math.floor(Date.now() / 1e3),
|
|
159
|
+
model: modelName,
|
|
160
|
+
system_fingerprint: null,
|
|
161
|
+
choices: [
|
|
162
|
+
{
|
|
163
|
+
text,
|
|
164
|
+
index: 0,
|
|
165
|
+
logprobs: null,
|
|
166
|
+
finish_reason: "stop"
|
|
167
|
+
}
|
|
168
|
+
],
|
|
169
|
+
usage
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
173
|
+
ctx.set({
|
|
174
|
+
"Content-Type": "text/event-stream",
|
|
175
|
+
"Cache-Control": "no-cache",
|
|
176
|
+
Connection: "keep-alive",
|
|
177
|
+
"X-Accel-Buffering": "no"
|
|
178
|
+
});
|
|
179
|
+
ctx.status = 200;
|
|
180
|
+
try {
|
|
181
|
+
const stream = await chatModel.stream(messages);
|
|
182
|
+
for await (const chunk of stream) {
|
|
183
|
+
let text = "";
|
|
184
|
+
if (typeof chunk.content === "string") {
|
|
185
|
+
text = chunk.content;
|
|
186
|
+
} else if (Array.isArray(chunk.content)) {
|
|
187
|
+
const textPart = chunk.content.find((c) => c.type === "text");
|
|
188
|
+
text = (textPart == null ? void 0 : textPart.text) || "";
|
|
189
|
+
}
|
|
190
|
+
if (text) {
|
|
191
|
+
ctx.res.write(
|
|
192
|
+
(0, import_openai_format.formatSSE)({
|
|
193
|
+
id: completionId,
|
|
194
|
+
object: "text_completion",
|
|
195
|
+
created: Math.floor(Date.now() / 1e3),
|
|
196
|
+
model: modelName,
|
|
197
|
+
system_fingerprint: null,
|
|
198
|
+
choices: [
|
|
199
|
+
{
|
|
200
|
+
text,
|
|
201
|
+
index: 0,
|
|
202
|
+
logprobs: null,
|
|
203
|
+
finish_reason: null
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
ctx.res.write(
|
|
211
|
+
(0, import_openai_format.formatSSE)({
|
|
212
|
+
id: completionId,
|
|
213
|
+
object: "text_completion",
|
|
214
|
+
created: Math.floor(Date.now() / 1e3),
|
|
215
|
+
model: modelName,
|
|
216
|
+
system_fingerprint: null,
|
|
217
|
+
choices: [
|
|
218
|
+
{
|
|
219
|
+
text: "",
|
|
220
|
+
index: 0,
|
|
221
|
+
logprobs: null,
|
|
222
|
+
finish_reason: "stop"
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
ctx.res.write((0, import_openai_format.formatSSEDone)());
|
|
228
|
+
} catch (err) {
|
|
229
|
+
ctx.log.error("AI API completions streaming error:", err);
|
|
230
|
+
ctx.res.write(
|
|
231
|
+
(0, import_openai_format.formatSSE)({
|
|
232
|
+
error: {
|
|
233
|
+
message: err.message || "Streaming error",
|
|
234
|
+
type: "server_error"
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
ctx.res.end();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
243
|
+
0 && (module.exports = {
|
|
244
|
+
handleCompletions
|
|
245
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Context } from '@nocobase/actions';
|
|
2
|
+
import type PluginAiApiServer from '../plugin';
|
|
3
|
+
/**
|
|
4
|
+
* POST /api/ai-llm/v1/embeddings
|
|
5
|
+
*
|
|
6
|
+
* OpenAI-compatible embeddings endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Supported providers (those with an `embedding` field in LLMProviderMeta):
|
|
9
|
+
* - openai → OpenAiEmbeddingProvider
|
|
10
|
+
* - openai-completions → OpenAiEmbeddingProvider
|
|
11
|
+
* - dashscope → DashscopeEmbeddingProvider
|
|
12
|
+
* - google-genai → GoogleGenAIEmbeddingProvider
|
|
13
|
+
* - ollama → OllamaEmbeddingProvider
|
|
14
|
+
*
|
|
15
|
+
* Not supported: anthropic, deepseek, kimi (no embedding provider registered).
|
|
16
|
+
*
|
|
17
|
+
* Limitations:
|
|
18
|
+
* - encoding_format 'base64' is not supported (always returns float arrays)
|
|
19
|
+
* - Token counts always return 0 (LangChain embeddings API doesn't expose this)
|
|
20
|
+
* - Token array input (integer[]) is not supported, only string input
|
|
21
|
+
*/
|
|
22
|
+
export declare function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer): Promise<void>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
var embeddings_exports = {};
|
|
19
|
+
__export(embeddings_exports, {
|
|
20
|
+
handleEmbeddings: () => handleEmbeddings
|
|
21
|
+
});
|
|
22
|
+
module.exports = __toCommonJS(embeddings_exports);
|
|
23
|
+
var import_openai_format = require("../utils/openai-format");
|
|
24
|
+
var import_resolve_service = require("../utils/resolve-service");
|
|
25
|
+
async function handleEmbeddings(ctx, plugin) {
|
|
26
|
+
var _a;
|
|
27
|
+
const body = ctx.request.body;
|
|
28
|
+
if (!(body == null ? void 0 : body.model)) {
|
|
29
|
+
ctx.status = 400;
|
|
30
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(400, "'model' is required", "invalid_request_error", "missing_model");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (body.input === void 0 || body.input === null) {
|
|
34
|
+
ctx.status = 400;
|
|
35
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(400, "'input' is required", "invalid_request_error", "missing_input");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (body.encoding_format === "base64") {
|
|
39
|
+
ctx.status = 400;
|
|
40
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
41
|
+
400,
|
|
42
|
+
"encoding_format 'base64' is not supported. Use 'float' (default) or omit the parameter.",
|
|
43
|
+
"invalid_request_error",
|
|
44
|
+
"unsupported_encoding_format"
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let inputs;
|
|
49
|
+
if (typeof body.input === "string") {
|
|
50
|
+
inputs = [body.input];
|
|
51
|
+
} else if (Array.isArray(body.input)) {
|
|
52
|
+
if (body.input.length === 0) {
|
|
53
|
+
ctx.status = 400;
|
|
54
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(400, "'input' array must not be empty", "invalid_request_error");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (typeof body.input[0] === "number") {
|
|
58
|
+
ctx.status = 400;
|
|
59
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
60
|
+
400,
|
|
61
|
+
"Token array input is not supported. Please provide string input.",
|
|
62
|
+
"invalid_request_error",
|
|
63
|
+
"unsupported_input_type"
|
|
64
|
+
);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
inputs = body.input;
|
|
68
|
+
} else {
|
|
69
|
+
ctx.status = 400;
|
|
70
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(400, "'input' must be a string or array of strings", "invalid_request_error");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const resolved = await (0, import_resolve_service.resolveModelString)(ctx, body.model);
|
|
74
|
+
if (!resolved) {
|
|
75
|
+
ctx.status = 404;
|
|
76
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
77
|
+
404,
|
|
78
|
+
`Could not resolve model '${body.model}'. Use GET /v1/models to list available models.`,
|
|
79
|
+
"invalid_request_error",
|
|
80
|
+
"model_not_found"
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { service, modelId } = resolved;
|
|
85
|
+
if (service.enabled === false) {
|
|
86
|
+
ctx.status = 404;
|
|
87
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
88
|
+
404,
|
|
89
|
+
`LLM service '${service.title || service.name}' is disabled`,
|
|
90
|
+
"invalid_request_error",
|
|
91
|
+
"model_not_found"
|
|
92
|
+
);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
97
|
+
if ((_a = config == null ? void 0 : config.enabledLlmServices) == null ? void 0 : _a.length) {
|
|
98
|
+
const allowed = config.enabledLlmServices.some(
|
|
99
|
+
(s) => s === service.name || s === service.title
|
|
100
|
+
);
|
|
101
|
+
if (!allowed) {
|
|
102
|
+
ctx.status = 403;
|
|
103
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
104
|
+
403,
|
|
105
|
+
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
106
|
+
"invalid_request_error",
|
|
107
|
+
"model_not_available"
|
|
108
|
+
);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
const aiPlugin = ctx.app.pm.get("ai");
|
|
115
|
+
if (!aiPlugin) {
|
|
116
|
+
ctx.status = 500;
|
|
117
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, "AI plugin not available", "server_error");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
121
|
+
if (!providerMeta) {
|
|
122
|
+
ctx.status = 500;
|
|
123
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!providerMeta.embedding) {
|
|
127
|
+
ctx.status = 400;
|
|
128
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
129
|
+
400,
|
|
130
|
+
`Provider '${providerMeta.title || service.provider}' does not support embeddings. Embedding-capable providers: openai, openai-completions, dashscope, google-genai, ollama.`,
|
|
131
|
+
"invalid_request_error",
|
|
132
|
+
"model_not_supported"
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const EmbeddingClass = providerMeta.embedding;
|
|
138
|
+
const embeddingProvider = new EmbeddingClass({
|
|
139
|
+
app: ctx.app,
|
|
140
|
+
serviceOptions: service.options,
|
|
141
|
+
// Contains apiKey, baseURL, etc.
|
|
142
|
+
modelOptions: { model: modelId }
|
|
143
|
+
// The specific embedding model
|
|
144
|
+
});
|
|
145
|
+
const embeddingModel = embeddingProvider.createEmbedding();
|
|
146
|
+
const vectors = await embeddingModel.embedDocuments(inputs);
|
|
147
|
+
ctx.status = 200;
|
|
148
|
+
ctx.set("Content-Type", "application/json");
|
|
149
|
+
ctx.body = (0, import_openai_format.toOpenAIEmbeddingsResponse)({
|
|
150
|
+
model: body.model,
|
|
151
|
+
embeddings: vectors,
|
|
152
|
+
// LangChain's EmbeddingsInterface does not expose token counts.
|
|
153
|
+
promptTokens: 0
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
ctx.log.error("AI API embeddings error:", err);
|
|
157
|
+
if (!ctx.res.headersSent) {
|
|
158
|
+
ctx.status = 500;
|
|
159
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, err.message || "Failed to generate embeddings", "server_error");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
164
|
+
0 && (module.exports = {
|
|
165
|
+
handleEmbeddings
|
|
166
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Context } from '@nocobase/actions';
|
|
2
|
+
import type PluginAiApiServer from '../plugin';
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/ai-llm/v1/models
|
|
5
|
+
*
|
|
6
|
+
* Lists all available models from enabled LLM services.
|
|
7
|
+
* Model IDs use the "serviceName/modelId" format (e.g. "my-openai/gpt-4o")
|
|
8
|
+
* so clients can copy-paste the ID directly into POST /v1/chat/completions
|
|
9
|
+
* without needing to configure a defaultLlmService.
|
|
10
|
+
*
|
|
11
|
+
* Backward compatibility: resolveModelString() in resolve-service.ts still
|
|
12
|
+
* accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
|
|
13
|
+
*/
|
|
14
|
+
export declare function handleListModels(ctx: Context, plugin: PluginAiApiServer): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* GET /api/ai-llm/v1/models/:model
|
|
17
|
+
*
|
|
18
|
+
* Retrieve a single model by ID.
|
|
19
|
+
* Accepts both "serviceName/modelId" format (new) and bare "modelId" (backward compat).
|
|
20
|
+
*/
|
|
21
|
+
export declare function handleGetModel(ctx: Context, modelId: string, plugin: PluginAiApiServer): Promise<void>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
var models_exports = {};
|
|
19
|
+
__export(models_exports, {
|
|
20
|
+
handleGetModel: () => handleGetModel,
|
|
21
|
+
handleListModels: () => handleListModels
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(models_exports);
|
|
24
|
+
var import_openai_format = require("../utils/openai-format");
|
|
25
|
+
async function handleListModels(ctx, plugin) {
|
|
26
|
+
var _a;
|
|
27
|
+
try {
|
|
28
|
+
const aiPlugin = ctx.app.pm.get("ai");
|
|
29
|
+
if (!aiPlugin) {
|
|
30
|
+
ctx.status = 500;
|
|
31
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, "AI plugin not available", "server_error");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const config = await getPluginConfig(ctx);
|
|
35
|
+
const filter = {};
|
|
36
|
+
if ((_a = config == null ? void 0 : config.enabledLlmServices) == null ? void 0 : _a.length) {
|
|
37
|
+
filter.$or = [
|
|
38
|
+
{ name: { $in: config.enabledLlmServices } },
|
|
39
|
+
{ title: { $in: config.enabledLlmServices } }
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
const services = await ctx.db.getRepository("llmServices").find({
|
|
43
|
+
filter,
|
|
44
|
+
sort: "sort"
|
|
45
|
+
});
|
|
46
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
47
|
+
const models = [];
|
|
48
|
+
for (const service of services) {
|
|
49
|
+
if (service.enabled === false) continue;
|
|
50
|
+
const enabledModels = resolveEnabledModels(service);
|
|
51
|
+
const serviceLabel = service.title || service.name;
|
|
52
|
+
for (const model of enabledModels) {
|
|
53
|
+
models.push({
|
|
54
|
+
// Use "serviceName/modelId" format so the ID can be used directly in
|
|
55
|
+
// POST /v1/chat/completions without ambiguity in multi-service setups.
|
|
56
|
+
id: `${service.name}/${model.value}`,
|
|
57
|
+
object: "model",
|
|
58
|
+
created: now,
|
|
59
|
+
owned_by: serviceLabel
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
ctx.status = 200;
|
|
64
|
+
ctx.body = {
|
|
65
|
+
object: "list",
|
|
66
|
+
data: models
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
ctx.log.error("AI API list models error:", err);
|
|
70
|
+
ctx.status = 500;
|
|
71
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, "Failed to list models", "server_error");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function handleGetModel(ctx, modelId, plugin) {
|
|
75
|
+
var _a;
|
|
76
|
+
try {
|
|
77
|
+
const config = await getPluginConfig(ctx);
|
|
78
|
+
const filter = {};
|
|
79
|
+
if ((_a = config == null ? void 0 : config.enabledLlmServices) == null ? void 0 : _a.length) {
|
|
80
|
+
filter.$or = [
|
|
81
|
+
{ name: { $in: config.enabledLlmServices } },
|
|
82
|
+
{ title: { $in: config.enabledLlmServices } }
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
const services = await ctx.db.getRepository("llmServices").find({
|
|
86
|
+
filter,
|
|
87
|
+
sort: "sort"
|
|
88
|
+
});
|
|
89
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
90
|
+
let found = null;
|
|
91
|
+
for (const service of services) {
|
|
92
|
+
if (service.enabled === false) continue;
|
|
93
|
+
const enabledModels = resolveEnabledModels(service);
|
|
94
|
+
const serviceLabel = service.title || service.name;
|
|
95
|
+
for (const model of enabledModels) {
|
|
96
|
+
const fullId = `${service.name}/${model.value}`;
|
|
97
|
+
if (fullId === modelId || model.value === modelId) {
|
|
98
|
+
found = {
|
|
99
|
+
id: fullId,
|
|
100
|
+
object: "model",
|
|
101
|
+
created: now,
|
|
102
|
+
owned_by: serviceLabel
|
|
103
|
+
};
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (found) break;
|
|
108
|
+
}
|
|
109
|
+
if (!found) {
|
|
110
|
+
ctx.status = 404;
|
|
111
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(404, `Model '${modelId}' not found`, "invalid_request_error", "model_not_found");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
ctx.status = 200;
|
|
115
|
+
ctx.body = found;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
ctx.log.error("AI API get model error:", err);
|
|
118
|
+
ctx.status = 500;
|
|
119
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(500, "Failed to retrieve model", "server_error");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function getPluginConfig(ctx) {
|
|
123
|
+
return ctx.db.getRepository("aiApiConfig").findOne();
|
|
124
|
+
}
|
|
125
|
+
function resolveEnabledModels(service) {
|
|
126
|
+
const raw = service.enabledModels;
|
|
127
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && raw.mode) {
|
|
128
|
+
if (raw.mode === "recommended") {
|
|
129
|
+
return getRecommendedModelsForProvider(service.provider);
|
|
130
|
+
}
|
|
131
|
+
return (raw.models || []).filter((m) => m.value).map((m) => ({ label: m.label || m.value, value: m.value }));
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(raw)) {
|
|
134
|
+
if (raw.length === 0) {
|
|
135
|
+
return getRecommendedModelsForProvider(service.provider);
|
|
136
|
+
}
|
|
137
|
+
return raw.map((id) => ({ label: id, value: id }));
|
|
138
|
+
}
|
|
139
|
+
return getRecommendedModelsForProvider(service.provider);
|
|
140
|
+
}
|
|
141
|
+
function getRecommendedModelsForProvider(provider) {
|
|
142
|
+
try {
|
|
143
|
+
const { getRecommendedModels } = require("@nocobase/plugin-ai/src/common/recommended-models");
|
|
144
|
+
const models = getRecommendedModels(provider);
|
|
145
|
+
return Array.isArray(models) ? models : [];
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
151
|
+
0 && (module.exports = {
|
|
152
|
+
handleGetModel,
|
|
153
|
+
handleListModels
|
|
154
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type PluginAiApiServer from '../plugin';
|
|
2
|
+
/**
|
|
3
|
+
* Main Koa middleware router for OpenAI-compatible endpoints.
|
|
4
|
+
*
|
|
5
|
+
* Intercepts all requests to /api/ai-llm/v1/* and routes them
|
|
6
|
+
* to the appropriate handler. Runs before NocoBase's resourcer
|
|
7
|
+
* so the URL paths follow OpenAI convention.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - CORS support (Access-Control-Allow-Origin: *)
|
|
11
|
+
* - OPTIONS preflight handling (204)
|
|
12
|
+
* - X-Request-Id on every response
|
|
13
|
+
* - Bearer token authentication
|
|
14
|
+
* - Sliding window rate limiting (enforces rateLimitPerMinute from config)
|
|
15
|
+
* - Structured request logging via app.logger
|
|
16
|
+
*
|
|
17
|
+
* Supported endpoints:
|
|
18
|
+
* POST /v1/chat/completions — OpenAI chat completions (LLM or agent mode)
|
|
19
|
+
* POST /v1/completions — Legacy text completions (LiteLLM compat)
|
|
20
|
+
* POST /v1/embeddings — OpenAI embeddings
|
|
21
|
+
* GET /v1/models — List available models
|
|
22
|
+
* GET /v1/models/:id — Get a single model
|
|
23
|
+
* DELETE /v1/models/:id — Not implemented (501 stub)
|
|
24
|
+
*/
|
|
25
|
+
export declare function createAiLlmRouter(plugin: PluginAiApiServer): (ctx: Context, next: Next) => Promise<any>;
|