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.
@@ -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 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
- }
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 any).withoutDataWrapping = true;
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: any) {
92
- const status = bodyErr?.statusCode === 413 ? 413 : 400;
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 model = (ctx.request.body as any)?.model ?? '-';
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 = await startUsageRecord(
127
- ctx,
128
- requestId,
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
- const mode = await resolveMode(ctx);
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 = await resolveMode(ctx);
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 any;
165
- if (reqBody?.prompt !== undefined) {
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