plugin-ai-api 1.0.3 → 1.0.6
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 +15 -1
- package/client-v2.d.ts +2 -0
- package/client-v2.js +1 -0
- package/dist/client/778.5c452944cb747975.js +10 -0
- package/dist/client/950.83390c5f1d5a97fb.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +10 -0
- package/dist/client-v2/index.js +10 -0
- package/dist/externalVersion.js +9 -8
- package/package.json +32 -14
- package/src/client/AiApiConfigPage.tsx +309 -0
- package/src/client/client.d.ts +258 -0
- package/src/client/components/AiApiRolePermissions.tsx +169 -0
- package/src/client/index.tsx +10 -0
- package/src/client/locale.ts +21 -0
- package/src/client/models/index.ts +12 -0
- package/src/client/plugin.tsx +48 -0
- package/src/client-v2/index.tsx +1 -0
- package/src/client-v2/plugin.tsx +24 -0
- package/src/index.ts +11 -0
- package/src/locale/en-US.json +10 -0
- package/src/locale/zh-CN.json +10 -0
- package/src/server/collections/.gitkeep +0 -0
- package/src/server/collections/ai-api-config.ts +51 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -0
- package/src/server/index.ts +10 -0
- package/src/server/middleware/rate-limit.ts +70 -0
- package/src/server/middleware/role-permission.ts +66 -0
- package/src/server/plugin.ts +89 -0
- package/src/server/resource/ai-api-config.ts +74 -0
- package/src/server/routes/agent-completions.ts +428 -0
- package/src/server/routes/auth.ts +111 -0
- package/src/server/routes/chat-completions.ts +318 -0
- package/src/server/routes/completions.ts +299 -0
- package/src/server/routes/embeddings.ts +191 -0
- package/src/server/routes/models.ts +195 -0
- package/src/server/routes/router.ts +283 -0
- package/src/server/utils/openai-format.ts +142 -0
- package/src/server/utils/rate-limiter.ts +83 -0
- package/src/server/utils/resolve-service.ts +82 -0
- package/src/swagger.ts +325 -0
- package/dist/client/23.e96ecf13e6072dce.js +0 -10
- package/dist/client/503.29bcdb426b01e715.js +0 -10
|
@@ -0,0 +1,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 type PluginAiApiServer from '../plugin';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POST /api/ai-llm/v1/embeddings
|
|
17
|
+
*
|
|
18
|
+
* OpenAI-compatible embeddings endpoint.
|
|
19
|
+
*
|
|
20
|
+
* Supported providers (those with an `embedding` field in LLMProviderMeta):
|
|
21
|
+
* - openai → OpenAiEmbeddingProvider
|
|
22
|
+
* - openai-completions → OpenAiEmbeddingProvider
|
|
23
|
+
* - dashscope → DashscopeEmbeddingProvider
|
|
24
|
+
* - google-genai → GoogleGenAIEmbeddingProvider
|
|
25
|
+
* - ollama → OllamaEmbeddingProvider
|
|
26
|
+
*
|
|
27
|
+
* Not supported: anthropic, deepseek, kimi (no embedding provider registered).
|
|
28
|
+
*
|
|
29
|
+
* Limitations:
|
|
30
|
+
* - encoding_format 'base64' is not supported (always returns float arrays)
|
|
31
|
+
* - Token counts always return 0 (LangChain embeddings API doesn't expose this)
|
|
32
|
+
* - Token array input (integer[]) is not supported, only string input
|
|
33
|
+
*/
|
|
34
|
+
export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer) {
|
|
35
|
+
const body = ctx.request.body as any;
|
|
36
|
+
|
|
37
|
+
// ─── Validate ────────────────────────────────────────────────────────────
|
|
38
|
+
if (!body?.model) {
|
|
39
|
+
ctx.status = 400;
|
|
40
|
+
ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (body.input === undefined || body.input === null) {
|
|
45
|
+
ctx.status = 400;
|
|
46
|
+
ctx.body = toOpenAIError(400, "'input' is required", 'invalid_request_error', 'missing_input');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (body.encoding_format === 'base64') {
|
|
51
|
+
ctx.status = 400;
|
|
52
|
+
ctx.body = toOpenAIError(
|
|
53
|
+
400,
|
|
54
|
+
"encoding_format 'base64' is not supported. Use 'float' (default) or omit the parameter.",
|
|
55
|
+
'invalid_request_error',
|
|
56
|
+
'unsupported_encoding_format',
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Normalize input to string[] ─────────────────────────────────────────
|
|
62
|
+
let inputs: string[];
|
|
63
|
+
if (typeof body.input === 'string') {
|
|
64
|
+
inputs = [body.input];
|
|
65
|
+
} else if (Array.isArray(body.input)) {
|
|
66
|
+
if (body.input.length === 0) {
|
|
67
|
+
ctx.status = 400;
|
|
68
|
+
ctx.body = toOpenAIError(400, "'input' array must not be empty", 'invalid_request_error');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (typeof body.input[0] === 'number') {
|
|
72
|
+
ctx.status = 400;
|
|
73
|
+
ctx.body = toOpenAIError(
|
|
74
|
+
400,
|
|
75
|
+
'Token array input is not supported. Please provide string input.',
|
|
76
|
+
'invalid_request_error',
|
|
77
|
+
'unsupported_input_type',
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
inputs = body.input as string[];
|
|
82
|
+
} else {
|
|
83
|
+
ctx.status = 400;
|
|
84
|
+
ctx.body = toOpenAIError(400, "'input' must be a string or array of strings", 'invalid_request_error');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Resolve model ────────────────────────────────────────────────────────
|
|
89
|
+
const resolved = await resolveModelString(ctx, body.model);
|
|
90
|
+
if (!resolved) {
|
|
91
|
+
ctx.status = 404;
|
|
92
|
+
ctx.body = toOpenAIError(
|
|
93
|
+
404,
|
|
94
|
+
`Could not resolve model '${body.model}'. Use GET /v1/models to list available models.`,
|
|
95
|
+
'invalid_request_error',
|
|
96
|
+
'model_not_found',
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { service, modelId } = resolved;
|
|
102
|
+
|
|
103
|
+
if (service.enabled === false) {
|
|
104
|
+
ctx.status = 404;
|
|
105
|
+
ctx.body = toOpenAIError(
|
|
106
|
+
404,
|
|
107
|
+
`LLM service '${service.title || service.name}' is disabled`,
|
|
108
|
+
'invalid_request_error',
|
|
109
|
+
'model_not_found',
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Check service whitelist ──────────────────────────────────────────────
|
|
115
|
+
try {
|
|
116
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
117
|
+
if (config?.enabledLlmServices?.length) {
|
|
118
|
+
const allowed = config.enabledLlmServices.some((s: string) => s === service.name || s === service.title);
|
|
119
|
+
if (!allowed) {
|
|
120
|
+
ctx.status = 403;
|
|
121
|
+
ctx.body = toOpenAIError(
|
|
122
|
+
403,
|
|
123
|
+
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
124
|
+
'invalid_request_error',
|
|
125
|
+
'model_not_available',
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Config read failure: fail open
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Get embedding provider ───────────────────────────────────────────────
|
|
135
|
+
const aiPlugin = ctx.app.pm.get('ai') as any;
|
|
136
|
+
if (!aiPlugin) {
|
|
137
|
+
ctx.status = 500;
|
|
138
|
+
ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
143
|
+
if (!providerMeta) {
|
|
144
|
+
ctx.status = 500;
|
|
145
|
+
ctx.body = toOpenAIError(500, `Provider '${service.provider}' not registered`, 'server_error');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// providerMeta.embedding is the EmbeddingProvider constructor (if supported by this provider)
|
|
150
|
+
if (!providerMeta.embedding) {
|
|
151
|
+
ctx.status = 400;
|
|
152
|
+
ctx.body = toOpenAIError(
|
|
153
|
+
400,
|
|
154
|
+
`Provider '${providerMeta.title || service.provider}' does not support embeddings. ` +
|
|
155
|
+
`Embedding-capable providers: openai, openai-completions, dashscope, google-genai, ollama.`,
|
|
156
|
+
'invalid_request_error',
|
|
157
|
+
'model_not_supported',
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// ─── Instantiate and call the embedding provider ──────────────────────
|
|
164
|
+
const EmbeddingClass = providerMeta.embedding;
|
|
165
|
+
const embeddingProvider = new EmbeddingClass({
|
|
166
|
+
app: ctx.app,
|
|
167
|
+
serviceOptions: service.options, // Contains apiKey, baseURL, etc.
|
|
168
|
+
modelOptions: { model: modelId }, // The specific embedding model
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// createEmbedding() returns a LangChain EmbeddingsInterface.
|
|
172
|
+
// embedDocuments() accepts string[] and returns number[][] (one vector per input).
|
|
173
|
+
const embeddingModel = embeddingProvider.createEmbedding();
|
|
174
|
+
const vectors: number[][] = await embeddingModel.embedDocuments(inputs);
|
|
175
|
+
|
|
176
|
+
ctx.status = 200;
|
|
177
|
+
ctx.set('Content-Type', 'application/json');
|
|
178
|
+
ctx.body = toOpenAIEmbeddingsResponse({
|
|
179
|
+
model: body.model,
|
|
180
|
+
embeddings: vectors,
|
|
181
|
+
// LangChain's EmbeddingsInterface does not expose token counts.
|
|
182
|
+
promptTokens: 0,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
ctx.log.error('AI API embeddings error:', err);
|
|
186
|
+
if (!ctx.res.headersSent) {
|
|
187
|
+
ctx.status = 500;
|
|
188
|
+
ctx.body = toOpenAIError(500, err.message || 'Failed to generate embeddings', 'server_error');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
import type PluginAiApiServer from '../plugin';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GET /api/ai-llm/v1/models
|
|
16
|
+
*
|
|
17
|
+
* Lists all available models from enabled LLM services.
|
|
18
|
+
* Model IDs use the "serviceName/modelId" format (e.g. "my-openai/gpt-4o")
|
|
19
|
+
* so clients can copy-paste the ID directly into POST /v1/chat/completions
|
|
20
|
+
* without needing to configure a defaultLlmService.
|
|
21
|
+
*
|
|
22
|
+
* Backward compatibility: resolveModelString() in resolve-service.ts still
|
|
23
|
+
* accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
|
|
24
|
+
*/
|
|
25
|
+
export async function handleListModels(ctx: Context, plugin: PluginAiApiServer) {
|
|
26
|
+
try {
|
|
27
|
+
const aiPlugin = ctx.app.pm.get('ai') as any;
|
|
28
|
+
if (!aiPlugin) {
|
|
29
|
+
ctx.status = 500;
|
|
30
|
+
ctx.body = toOpenAIError(500, 'AI plugin not available', 'server_error');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = await getPluginConfig(ctx);
|
|
35
|
+
const filter: any = {};
|
|
36
|
+
|
|
37
|
+
// If whitelist is set, apply it (match by name OR title)
|
|
38
|
+
if (config?.enabledLlmServices?.length) {
|
|
39
|
+
filter.$or = [{ name: { $in: config.enabledLlmServices } }, { title: { $in: config.enabledLlmServices } }];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const services = await ctx.db.getRepository('llmServices').find({
|
|
43
|
+
filter,
|
|
44
|
+
sort: 'sort',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const now = Math.floor(Date.now() / 1000);
|
|
48
|
+
const models: any[] = [];
|
|
49
|
+
|
|
50
|
+
for (const service of services) {
|
|
51
|
+
if (service.enabled === false) continue;
|
|
52
|
+
|
|
53
|
+
const enabledModels = resolveEnabledModels(service);
|
|
54
|
+
const serviceLabel = service.title || service.name;
|
|
55
|
+
|
|
56
|
+
for (const model of enabledModels) {
|
|
57
|
+
models.push({
|
|
58
|
+
// Use "serviceName/modelId" format so the ID can be used directly in
|
|
59
|
+
// POST /v1/chat/completions without ambiguity in multi-service setups.
|
|
60
|
+
id: `${service.name}/${model.value}`,
|
|
61
|
+
object: 'model',
|
|
62
|
+
created: now,
|
|
63
|
+
owned_by: serviceLabel,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ctx.status = 200;
|
|
69
|
+
ctx.body = {
|
|
70
|
+
object: 'list',
|
|
71
|
+
data: models,
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
ctx.log.error('AI API list models error:', err);
|
|
75
|
+
ctx.status = 500;
|
|
76
|
+
ctx.body = toOpenAIError(500, 'Failed to list models', 'server_error');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* GET /api/ai-llm/v1/models/:model
|
|
82
|
+
*
|
|
83
|
+
* Retrieve a single model by ID.
|
|
84
|
+
* Accepts both "serviceName/modelId" format (new) and bare "modelId" (backward compat).
|
|
85
|
+
*/
|
|
86
|
+
export async function handleGetModel(ctx: Context, modelId: string, plugin: PluginAiApiServer) {
|
|
87
|
+
try {
|
|
88
|
+
const config = await getPluginConfig(ctx);
|
|
89
|
+
const filter: any = {};
|
|
90
|
+
|
|
91
|
+
if (config?.enabledLlmServices?.length) {
|
|
92
|
+
filter.$or = [{ name: { $in: config.enabledLlmServices } }, { title: { $in: config.enabledLlmServices } }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const services = await ctx.db.getRepository('llmServices').find({
|
|
96
|
+
filter,
|
|
97
|
+
sort: 'sort',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const now = Math.floor(Date.now() / 1000);
|
|
101
|
+
let found: any = null;
|
|
102
|
+
|
|
103
|
+
for (const service of services) {
|
|
104
|
+
if (service.enabled === false) continue;
|
|
105
|
+
const enabledModels = resolveEnabledModels(service);
|
|
106
|
+
const serviceLabel = service.title || service.name;
|
|
107
|
+
|
|
108
|
+
for (const model of enabledModels) {
|
|
109
|
+
const fullId = `${service.name}/${model.value}`;
|
|
110
|
+
// Accept both new "serviceName/modelId" format AND bare model ID (backward compat)
|
|
111
|
+
if (fullId === modelId || model.value === modelId) {
|
|
112
|
+
found = {
|
|
113
|
+
id: fullId,
|
|
114
|
+
object: 'model',
|
|
115
|
+
created: now,
|
|
116
|
+
owned_by: serviceLabel,
|
|
117
|
+
};
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (found) break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!found) {
|
|
125
|
+
ctx.status = 404;
|
|
126
|
+
ctx.body = toOpenAIError(404, `Model '${modelId}' not found`, 'invalid_request_error', 'model_not_found');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ctx.status = 200;
|
|
131
|
+
ctx.body = found;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
ctx.log.error('AI API get model error:', err);
|
|
134
|
+
ctx.status = 500;
|
|
135
|
+
ctx.body = toOpenAIError(500, 'Failed to retrieve model', 'server_error');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Helpers ───
|
|
140
|
+
|
|
141
|
+
async function getPluginConfig(ctx: Context) {
|
|
142
|
+
return ctx.db.getRepository('aiApiConfig').findOne();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve enabled models for a service.
|
|
147
|
+
* Mirrors the logic from plugin-ai's ai.ts listAllEnabledModels.
|
|
148
|
+
*
|
|
149
|
+
* For 'recommended' mode, we now correctly fetch the official model list
|
|
150
|
+
* from plugin-ai's shared recommended-models module.
|
|
151
|
+
*/
|
|
152
|
+
function resolveEnabledModels(service: any): { label: string; value: string }[] {
|
|
153
|
+
const raw = service.enabledModels;
|
|
154
|
+
|
|
155
|
+
// Handle new { mode, models } format
|
|
156
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw) && raw.mode) {
|
|
157
|
+
if (raw.mode === 'recommended') {
|
|
158
|
+
return getRecommendedModelsForProvider(service.provider);
|
|
159
|
+
}
|
|
160
|
+
// 'provider' or 'custom' mode with explicitly listed models
|
|
161
|
+
return (raw.models || [])
|
|
162
|
+
.filter((m: any) => m.value)
|
|
163
|
+
.map((m: any) => ({ label: m.label || m.value, value: m.value }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Backward compat: old string[] format
|
|
167
|
+
if (Array.isArray(raw)) {
|
|
168
|
+
if (raw.length === 0) {
|
|
169
|
+
// Empty array means no explicit models — fall back to recommended
|
|
170
|
+
return getRecommendedModelsForProvider(service.provider);
|
|
171
|
+
}
|
|
172
|
+
return raw.map((id: string) => ({ label: id, value: id }));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// null/undefined — no explicit models set, fall back to recommended
|
|
176
|
+
return getRecommendedModelsForProvider(service.provider);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get recommended models for a provider from plugin-ai's shared module.
|
|
181
|
+
*
|
|
182
|
+
* Uses dynamic require() to avoid a hard compile-time import path dependency.
|
|
183
|
+
* plugin-ai is always available at runtime (it's a peerDependency).
|
|
184
|
+
* Falls back to [] if the module is unavailable or the provider has no recommendations.
|
|
185
|
+
*/
|
|
186
|
+
function getRecommendedModelsForProvider(provider: string): { label: string; value: string }[] {
|
|
187
|
+
try {
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
189
|
+
const { getRecommendedModels } = require('@nocobase/plugin-ai/src/common/recommended-models');
|
|
190
|
+
const models = getRecommendedModels(provider);
|
|
191
|
+
return Array.isArray(models) ? models : [];
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
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
|
+
import { Context, Next } from '@nocobase/actions';
|
|
12
|
+
import { authenticateBearer } from './auth';
|
|
13
|
+
import { handleListModels, handleGetModel } from './models';
|
|
14
|
+
import { handleChatCompletions } from './chat-completions';
|
|
15
|
+
import { handleCompletions } from './completions';
|
|
16
|
+
import { handleAgentCompletions } from './agent-completions';
|
|
17
|
+
import { handleEmbeddings } from './embeddings';
|
|
18
|
+
import { toOpenAIError } from '../utils/openai-format';
|
|
19
|
+
import { createRateLimitMiddleware } from '../middleware/rate-limit';
|
|
20
|
+
import { checkRolePermission } from '../middleware/role-permission';
|
|
21
|
+
import type PluginAiApiServer from '../plugin';
|
|
22
|
+
|
|
23
|
+
const API_PREFIX = '/api/ai-llm/v1';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Main Koa middleware router for OpenAI-compatible endpoints.
|
|
27
|
+
*
|
|
28
|
+
* Intercepts all requests to /api/ai-llm/v1/* and routes them
|
|
29
|
+
* to the appropriate handler. Runs before NocoBase's resourcer
|
|
30
|
+
* so the URL paths follow OpenAI convention.
|
|
31
|
+
*
|
|
32
|
+
* Features:
|
|
33
|
+
* - CORS support (Access-Control-Allow-Origin: *)
|
|
34
|
+
* - OPTIONS preflight handling (204)
|
|
35
|
+
* - X-Request-Id on every response
|
|
36
|
+
* - Bearer token authentication
|
|
37
|
+
* - Sliding window rate limiting (enforces rateLimitPerMinute from config)
|
|
38
|
+
* - Structured request logging via app.logger
|
|
39
|
+
*
|
|
40
|
+
* Supported endpoints:
|
|
41
|
+
* POST /v1/chat/completions — OpenAI chat completions (LLM or agent mode)
|
|
42
|
+
* POST /v1/completions — Legacy text completions (LiteLLM compat)
|
|
43
|
+
* POST /v1/embeddings — OpenAI embeddings
|
|
44
|
+
* GET /v1/models — List available models
|
|
45
|
+
* GET /v1/models/:id — Get a single model
|
|
46
|
+
* DELETE /v1/models/:id — Not implemented (501 stub)
|
|
47
|
+
*/
|
|
48
|
+
export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
49
|
+
const checkRateLimit = createRateLimitMiddleware(plugin.rateLimiter);
|
|
50
|
+
|
|
51
|
+
return async (ctx: Context, next: Next) => {
|
|
52
|
+
const { path, method } = ctx;
|
|
53
|
+
|
|
54
|
+
// Only handle our prefix
|
|
55
|
+
if (!path.startsWith(API_PREFIX)) {
|
|
56
|
+
return next();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Prevent NocoBase's dataWrapping middleware from wrapping OpenAI-format responses
|
|
60
|
+
// in an extra {"data": ...} envelope, which breaks OpenAI-compatible clients like n8n.
|
|
61
|
+
(ctx as any).withoutDataWrapping = true;
|
|
62
|
+
|
|
63
|
+
// Parse the sub-path after prefix
|
|
64
|
+
const subPath = path.substring(API_PREFIX.length);
|
|
65
|
+
|
|
66
|
+
// ─── CORS — applies to all requests, including preflight ──────────────
|
|
67
|
+
// '*' is safe here because all endpoints require Bearer token auth.
|
|
68
|
+
// Browsers cannot send cookies to '*' origins, but Authorization headers work fine.
|
|
69
|
+
ctx.set('Access-Control-Allow-Origin', '*');
|
|
70
|
+
ctx.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
71
|
+
ctx.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-AI-Mode, X-Timezone, X-Locale');
|
|
72
|
+
ctx.set('Access-Control-Expose-Headers', 'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After');
|
|
73
|
+
ctx.set('Access-Control-Max-Age', '86400');
|
|
74
|
+
|
|
75
|
+
// ─── OPTIONS preflight — return immediately after CORS headers ────────
|
|
76
|
+
if (method === 'OPTIONS') {
|
|
77
|
+
ctx.status = 204;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Request ID — set before any response ─────────────────────────────
|
|
82
|
+
const requestId = `req-${crypto.randomBytes(12).toString('hex')}`;
|
|
83
|
+
ctx.set('X-Request-Id', requestId);
|
|
84
|
+
|
|
85
|
+
// ─── Parse body for POST requests if not already parsed ───────────────
|
|
86
|
+
if (method === 'POST' && !ctx.request.body) {
|
|
87
|
+
try {
|
|
88
|
+
const rawBody = await getRawBody(ctx);
|
|
89
|
+
ctx.request.body = JSON.parse(rawBody);
|
|
90
|
+
} catch (bodyErr: any) {
|
|
91
|
+
const status = bodyErr?.statusCode === 413 ? 413 : 400;
|
|
92
|
+
const message = status === 413 ? 'Request body too large (max 10 MB)' : 'Invalid JSON in request body';
|
|
93
|
+
ctx.status = status;
|
|
94
|
+
ctx.body = toOpenAIError(status, message, 'invalid_request_error');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Authenticate ─────────────────────────────────────────────────────
|
|
100
|
+
const isAuth = await authenticateBearer(ctx);
|
|
101
|
+
if (!isAuth) {
|
|
102
|
+
logRequest(ctx, requestId, '-', 'auth_failed', 0);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Role permission check ────────────────────────────────────────────
|
|
107
|
+
const permitted = await checkRolePermission(ctx);
|
|
108
|
+
if (!permitted) {
|
|
109
|
+
logRequest(ctx, requestId, '-', 'forbidden', 0);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Rate limiting ────────────────────────────────────────────────────
|
|
114
|
+
const allowed = await checkRateLimit(ctx);
|
|
115
|
+
if (!allowed) {
|
|
116
|
+
logRequest(ctx, requestId, '-', 'rate_limited', 0);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Route matching ───────────────────────────────────────────────────
|
|
121
|
+
const model = (ctx.request.body as any)?.model ?? '-';
|
|
122
|
+
const t0 = Date.now();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// POST /v1/chat/completions — route based on mode
|
|
126
|
+
if (method === 'POST' && subPath === '/chat/completions') {
|
|
127
|
+
const mode = await resolveMode(ctx);
|
|
128
|
+
await (mode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
|
|
129
|
+
logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// POST /v1/embeddings
|
|
134
|
+
if (method === 'POST' && subPath === '/embeddings') {
|
|
135
|
+
await handleEmbeddings(ctx, plugin);
|
|
136
|
+
logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// POST /v1/completions (legacy text completions — used by LiteLLM)
|
|
141
|
+
if (method === 'POST' && subPath === '/completions') {
|
|
142
|
+
const completionsMode = await resolveMode(ctx);
|
|
143
|
+
if (completionsMode === 'agent') {
|
|
144
|
+
// Convert legacy prompt → messages format for agent handler
|
|
145
|
+
const reqBody = ctx.request.body as any;
|
|
146
|
+
if (reqBody?.prompt !== undefined) {
|
|
147
|
+
const prompt =
|
|
148
|
+
typeof reqBody.prompt === 'string'
|
|
149
|
+
? reqBody.prompt
|
|
150
|
+
: Array.isArray(reqBody.prompt)
|
|
151
|
+
? reqBody.prompt.join('\n')
|
|
152
|
+
: String(reqBody.prompt);
|
|
153
|
+
ctx.request.body = { ...reqBody, messages: [{ role: 'user', content: prompt }] };
|
|
154
|
+
}
|
|
155
|
+
await handleAgentCompletions(ctx, plugin);
|
|
156
|
+
} else {
|
|
157
|
+
await handleCompletions(ctx, plugin);
|
|
158
|
+
}
|
|
159
|
+
logRequest(ctx, requestId, model, 'ok', Date.now() - t0);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// GET /v1/models
|
|
164
|
+
if (method === 'GET' && subPath === '/models') {
|
|
165
|
+
await handleListModels(ctx, plugin);
|
|
166
|
+
logRequest(ctx, requestId, '-', 'ok', Date.now() - t0);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// GET /v1/models/:model (model can contain '/' for service/model format)
|
|
171
|
+
if (method === 'GET' && subPath.startsWith('/models/')) {
|
|
172
|
+
const modelId = subPath.substring('/models/'.length);
|
|
173
|
+
if (modelId) {
|
|
174
|
+
await handleGetModel(ctx, decodeURIComponent(modelId), plugin);
|
|
175
|
+
logRequest(ctx, requestId, modelId, 'ok', Date.now() - t0);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// DELETE /v1/models/:model — stub (OpenAI fine-tune model deletion, not applicable here)
|
|
181
|
+
if (method === 'DELETE' && subPath.startsWith('/models/')) {
|
|
182
|
+
ctx.status = 501;
|
|
183
|
+
ctx.body = toOpenAIError(
|
|
184
|
+
501,
|
|
185
|
+
'Model deletion is not supported by this API gateway. ' +
|
|
186
|
+
'Use the NocoBase admin panel to manage LLM services.',
|
|
187
|
+
'invalid_request_error',
|
|
188
|
+
'not_implemented',
|
|
189
|
+
);
|
|
190
|
+
logRequest(ctx, requestId, '-', 'not_implemented', Date.now() - t0);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Unsupported endpoint ──────────────────────────────────────────
|
|
195
|
+
ctx.status = 404;
|
|
196
|
+
ctx.body = toOpenAIError(
|
|
197
|
+
404,
|
|
198
|
+
`Unknown endpoint: ${method} ${path}. ` +
|
|
199
|
+
`Supported: POST /v1/chat/completions, POST /v1/completions, POST /v1/embeddings, GET /v1/models`,
|
|
200
|
+
'invalid_request_error',
|
|
201
|
+
'unknown_url',
|
|
202
|
+
);
|
|
203
|
+
logRequest(ctx, requestId, '-', 'not_found', Date.now() - t0);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
ctx.log.error('AI API router error:', err);
|
|
206
|
+
logRequest(ctx, requestId, model, 'error', Date.now() - t0);
|
|
207
|
+
if (!ctx.res.headersSent) {
|
|
208
|
+
ctx.status = 500;
|
|
209
|
+
ctx.body = toOpenAIError(500, err.message || 'Internal server error', 'server_error');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Maximum allowed request body size (10 MB) to prevent OOM DoS attacks. */
|
|
216
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read raw body from request stream (fallback if bodyparser didn't handle it).
|
|
220
|
+
* Rejects with a 413-style error if the body exceeds MAX_BODY_BYTES.
|
|
221
|
+
*/
|
|
222
|
+
function getRawBody(ctx: Context): Promise<string> {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
let body = '';
|
|
225
|
+
let byteCount = 0;
|
|
226
|
+
|
|
227
|
+
ctx.req.on('data', (chunk: Buffer) => {
|
|
228
|
+
byteCount += chunk.length;
|
|
229
|
+
if (byteCount > MAX_BODY_BYTES) {
|
|
230
|
+
ctx.req.destroy();
|
|
231
|
+
reject(Object.assign(new Error('Request body too large (max 10 MB)'), { statusCode: 413 }));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
body += chunk.toString();
|
|
235
|
+
});
|
|
236
|
+
ctx.req.on('end', () => resolve(body));
|
|
237
|
+
ctx.req.on('error', reject);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Determine the API mode for a request.
|
|
243
|
+
*
|
|
244
|
+
* Priority:
|
|
245
|
+
* 1. X-AI-Mode request header ('llm' or 'agent')
|
|
246
|
+
* 2. Config `mode` field from aiApiConfig
|
|
247
|
+
* 3. Default: 'llm'
|
|
248
|
+
*/
|
|
249
|
+
async function resolveMode(ctx: Context): Promise<'llm' | 'agent'> {
|
|
250
|
+
const headerMode = ctx.get('X-AI-Mode')?.toLowerCase();
|
|
251
|
+
if (headerMode === 'agent' || headerMode === 'llm') {
|
|
252
|
+
ctx.app.logger?.info(`[ai-api] Mode resolved from header: ${headerMode}`);
|
|
253
|
+
return headerMode;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
258
|
+
if (config) {
|
|
259
|
+
const dbMode = config.get('mode') || config.mode;
|
|
260
|
+
if (dbMode === 'agent' || dbMode === 'llm') {
|
|
261
|
+
ctx.app.logger?.info(`[ai-api] Mode resolved from DB config: ${dbMode}`);
|
|
262
|
+
return dbMode as 'llm' | 'agent';
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
ctx.app.logger?.error('[ai-api] Failed to get mode from config:', err);
|
|
267
|
+
// Ignore config errors — default to llm
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
ctx.app.logger?.info(`[ai-api] Mode fallback to default: llm`);
|
|
271
|
+
return 'llm';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Write a structured log line for every handled request.
|
|
276
|
+
*/
|
|
277
|
+
function logRequest(ctx: Context, requestId: string, model: string, status: string, durationMs: number): void {
|
|
278
|
+
const userId = ctx.state.currentUser?.id ?? 'anon';
|
|
279
|
+
ctx.app.logger?.info(
|
|
280
|
+
`[ai-api] ${ctx.method} ${ctx.path} requestId=${requestId} userId=${userId} ` +
|
|
281
|
+
`model=${model} status=${status} duration=${durationMs}ms`,
|
|
282
|
+
);
|
|
283
|
+
}
|