plugin-ai-api 1.0.11 → 1.0.13
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/server/routes/chat-completions.js +60 -10
- package/dist/server/routes/embeddings.js +1 -1
- package/dist/server/routes/router.js +21 -13
- package/dist/server/usage.js +19 -6
- package/dist/server/utils/openai-format.js +2 -2
- package/package.json +1 -1
- package/src/server/__tests__/openai-format.test.ts +56 -0
- package/src/server/routes/chat-completions.ts +78 -9
- package/src/server/routes/embeddings.ts +191 -191
- package/src/server/routes/router.ts +21 -10
- package/src/server/usage.ts +28 -5
- package/src/server/utils/openai-format.ts +7 -3
|
@@ -26,6 +26,8 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
26
26
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
27
27
|
var chat_completions_exports = {};
|
|
28
28
|
__export(chat_completions_exports, {
|
|
29
|
+
applyProviderRequestParameters: () => applyProviderRequestParameters,
|
|
30
|
+
getProviderRequestParameters: () => getProviderRequestParameters,
|
|
29
31
|
handleChatCompletions: () => handleChatCompletions
|
|
30
32
|
});
|
|
31
33
|
module.exports = __toCommonJS(chat_completions_exports);
|
|
@@ -108,13 +110,15 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
108
110
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
109
111
|
return;
|
|
110
112
|
}
|
|
113
|
+
const providerRequestParameters = getProviderRequestParameters(body);
|
|
111
114
|
const modelOptions = {
|
|
112
115
|
model: modelId,
|
|
113
116
|
llmService: service.name
|
|
114
117
|
};
|
|
115
118
|
if (body.temperature !== void 0) modelOptions.temperature = body.temperature;
|
|
116
119
|
if (body.top_p !== void 0) modelOptions.topP = body.top_p;
|
|
117
|
-
if (body.
|
|
120
|
+
if (body.max_completion_tokens !== void 0) modelOptions.maxTokens = body.max_completion_tokens;
|
|
121
|
+
else if (body.max_tokens !== void 0) modelOptions.maxTokens = body.max_tokens;
|
|
118
122
|
if (body.frequency_penalty !== void 0) modelOptions.frequencyPenalty = body.frequency_penalty;
|
|
119
123
|
if (body.presence_penalty !== void 0) modelOptions.presencePenalty = body.presence_penalty;
|
|
120
124
|
if (body.stop !== void 0) modelOptions.stop = body.stop;
|
|
@@ -166,11 +170,26 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
166
170
|
});
|
|
167
171
|
const completionId = (0, import_openai_format.generateCompletionId)();
|
|
168
172
|
const baseModel = provider.createModel();
|
|
169
|
-
|
|
173
|
+
applyProviderRequestParameters(baseModel, providerRequestParameters);
|
|
174
|
+
const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice, providerRequestParameters);
|
|
170
175
|
if (stream) {
|
|
171
|
-
await handleStreamingCompletion(
|
|
176
|
+
await handleStreamingCompletion(
|
|
177
|
+
ctx,
|
|
178
|
+
chatModel,
|
|
179
|
+
langchainMessages,
|
|
180
|
+
completionId,
|
|
181
|
+
body.model,
|
|
182
|
+
providerRequestParameters
|
|
183
|
+
);
|
|
172
184
|
} else {
|
|
173
|
-
await handleNonStreamingCompletion(
|
|
185
|
+
await handleNonStreamingCompletion(
|
|
186
|
+
ctx,
|
|
187
|
+
chatModel,
|
|
188
|
+
langchainMessages,
|
|
189
|
+
completionId,
|
|
190
|
+
body.model,
|
|
191
|
+
providerRequestParameters
|
|
192
|
+
);
|
|
174
193
|
}
|
|
175
194
|
} catch (err) {
|
|
176
195
|
ctx.log.error("AI API chat completions error:", err);
|
|
@@ -180,8 +199,8 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
180
199
|
}
|
|
181
200
|
}
|
|
182
201
|
}
|
|
183
|
-
async function handleNonStreamingCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
184
|
-
const result = await chatModel.invoke(messages);
|
|
202
|
+
async function handleNonStreamingCompletion(ctx, chatModel, messages, completionId, modelName, providerRequestParameters) {
|
|
203
|
+
const result = await chatModel.invoke(messages, providerRequestParameters);
|
|
185
204
|
let content = "";
|
|
186
205
|
if (typeof result.content === "string") {
|
|
187
206
|
content = result.content;
|
|
@@ -204,7 +223,7 @@ async function handleNonStreamingCompletion(ctx, chatModel, messages, completion
|
|
|
204
223
|
toolCalls
|
|
205
224
|
});
|
|
206
225
|
}
|
|
207
|
-
async function handleStreamingCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
226
|
+
async function handleStreamingCompletion(ctx, chatModel, messages, completionId, modelName, providerRequestParameters) {
|
|
208
227
|
ctx.set({
|
|
209
228
|
"Content-Type": "text/event-stream",
|
|
210
229
|
"Cache-Control": "no-cache",
|
|
@@ -227,7 +246,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
227
246
|
let usage;
|
|
228
247
|
let finishReason = "stop";
|
|
229
248
|
try {
|
|
230
|
-
const stream = await chatModel.stream(messages, { signal: requestAbort.signal });
|
|
249
|
+
const stream = await chatModel.stream(messages, { ...providerRequestParameters, signal: requestAbort.signal });
|
|
231
250
|
for await (const chunk of stream) {
|
|
232
251
|
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
233
252
|
let content = "";
|
|
@@ -300,12 +319,41 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
300
319
|
function getErrorMessage(error, fallback) {
|
|
301
320
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
302
321
|
}
|
|
303
|
-
|
|
322
|
+
const GATEWAY_MANAGED_PARAMETERS = /* @__PURE__ */ new Set(["model", "messages", "tools", "tool_choice", "stream", "n"]);
|
|
323
|
+
function getProviderRequestParameters(body) {
|
|
324
|
+
return Object.fromEntries(
|
|
325
|
+
Object.entries(body).filter(([name, value]) => !GATEWAY_MANAGED_PARAMETERS.has(name) && value !== void 0)
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
function applyProviderRequestParameters(chatModel, parameters) {
|
|
329
|
+
if (!chatModel || typeof chatModel !== "object") return;
|
|
330
|
+
const model = chatModel;
|
|
331
|
+
const modelKwargs = { ...model.modelKwargs ?? {} };
|
|
332
|
+
if (!Object.hasOwn(parameters, "response_format")) {
|
|
333
|
+
if (isDefaultTextResponseFormat(modelKwargs.response_format)) delete modelKwargs.response_format;
|
|
334
|
+
if (isDefaultResponsesTextFormat(modelKwargs.text)) delete modelKwargs.text;
|
|
335
|
+
}
|
|
336
|
+
model.modelKwargs = { ...modelKwargs, ...parameters };
|
|
337
|
+
}
|
|
338
|
+
function bindRequestTools(chatModel, tools, toolChoice, providerRequestParameters) {
|
|
304
339
|
if (!Array.isArray(tools) || tools.length === 0) return chatModel;
|
|
305
340
|
if (typeof chatModel.bindTools !== "function") {
|
|
306
341
|
throw new Error("The selected LLM provider does not support tool calling");
|
|
307
342
|
}
|
|
308
|
-
return chatModel.bindTools(tools,
|
|
343
|
+
return chatModel.bindTools(tools, {
|
|
344
|
+
...providerRequestParameters,
|
|
345
|
+
...toolChoice === void 0 ? {} : { tool_choice: toolChoice }
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function isDefaultTextResponseFormat(value) {
|
|
349
|
+
return isRecord(value) && value.type === "text" && Object.keys(value).length === 1;
|
|
350
|
+
}
|
|
351
|
+
function isDefaultResponsesTextFormat(value) {
|
|
352
|
+
if (!isRecord(value) || !isRecord(value.format)) return false;
|
|
353
|
+
return value.format.type === "text" && Object.keys(value.format).length === 1 && Object.keys(value).length === 1;
|
|
354
|
+
}
|
|
355
|
+
function isRecord(value) {
|
|
356
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
309
357
|
}
|
|
310
358
|
function normalizeToolCalls(value) {
|
|
311
359
|
if (!Array.isArray(value) || value.length === 0) return void 0;
|
|
@@ -340,5 +388,7 @@ function serializeToolArguments(value) {
|
|
|
340
388
|
}
|
|
341
389
|
// Annotate the CommonJS export names for ESM import in node:
|
|
342
390
|
0 && (module.exports = {
|
|
391
|
+
applyProviderRequestParameters,
|
|
392
|
+
getProviderRequestParameters,
|
|
343
393
|
handleChatCompletions
|
|
344
394
|
});
|
|
@@ -157,7 +157,7 @@ async function handleEmbeddings(ctx, plugin) {
|
|
|
157
157
|
model: body.model,
|
|
158
158
|
embeddings: vectors,
|
|
159
159
|
// LangChain's EmbeddingsInterface does not expose token counts.
|
|
160
|
-
promptTokens:
|
|
160
|
+
promptTokens: null
|
|
161
161
|
});
|
|
162
162
|
} catch (err) {
|
|
163
163
|
ctx.log.error("AI API embeddings error:", err);
|
|
@@ -50,11 +50,12 @@ var import_openai_format = require("../utils/openai-format");
|
|
|
50
50
|
var import_rate_limit = require("../middleware/rate-limit");
|
|
51
51
|
var import_role_permission = require("../middleware/role-permission");
|
|
52
52
|
var import_usage = require("../usage");
|
|
53
|
+
var import_streaming = require("../utils/streaming");
|
|
53
54
|
const API_PREFIX = "/api/ai-llm/v1";
|
|
54
55
|
function createAiLlmRouter(plugin) {
|
|
55
56
|
const checkRateLimit = (0, import_rate_limit.createRateLimitMiddleware)(plugin.rateLimiter);
|
|
56
57
|
return async (ctx, next) => {
|
|
57
|
-
var _a, _b, _c
|
|
58
|
+
var _a, _b, _c;
|
|
58
59
|
const { path, method } = ctx;
|
|
59
60
|
if (!path.startsWith(API_PREFIX)) {
|
|
60
61
|
return next();
|
|
@@ -100,28 +101,35 @@ function createAiLlmRouter(plugin) {
|
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
103
|
const model = ((_a = ctx.request.body) == null ? void 0 : _a.model) ?? "-";
|
|
104
|
+
const isUsageEndpoint = method === "POST" && (subPath === "/chat/completions" || subPath === "/completions" || subPath === "/embeddings");
|
|
105
|
+
const resolvedMode = isUsageEndpoint ? await resolveMode(ctx) : "llm";
|
|
106
|
+
const requestBody = ctx.request.body || {};
|
|
107
|
+
const streaming = isUsageEndpoint && (0, import_streaming.isStreamingRequested)(requestBody.stream);
|
|
108
|
+
if (streaming && (subPath === "/chat/completions" || subPath === "/completions")) {
|
|
109
|
+
const streamOptions = requestBody.stream_options;
|
|
110
|
+
ctx.request.body = {
|
|
111
|
+
...requestBody,
|
|
112
|
+
stream_options: {
|
|
113
|
+
...streamOptions && typeof streamOptions === "object" ? streamOptions : {},
|
|
114
|
+
include_usage: true
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
103
118
|
const t0 = Date.now();
|
|
104
119
|
let usageId;
|
|
105
120
|
try {
|
|
106
|
-
usageId = await (0, import_usage.startUsageRecord)(
|
|
107
|
-
ctx,
|
|
108
|
-
requestId,
|
|
109
|
-
subPath,
|
|
110
|
-
String(model),
|
|
111
|
-
Boolean((_b = ctx.request.body) == null ? void 0 : _b.stream)
|
|
112
|
-
);
|
|
121
|
+
usageId = isUsageEndpoint ? await (0, import_usage.startUsageRecord)(ctx, requestId, subPath, String(model), streaming, resolvedMode) : void 0;
|
|
113
122
|
} catch (usageError) {
|
|
114
123
|
ctx.log.error("AI API usage record could not be created:", usageError);
|
|
115
124
|
}
|
|
116
125
|
try {
|
|
117
126
|
if (method === "POST" && subPath === "/chat/completions") {
|
|
118
|
-
|
|
119
|
-
await (mode === "agent" ? (0, import_agent_completions.handleAgentCompletions)(ctx, plugin) : (0, import_chat_completions.handleChatCompletions)(ctx, plugin));
|
|
127
|
+
await (resolvedMode === "agent" ? (0, import_agent_completions.handleAgentCompletions)(ctx, plugin) : (0, import_chat_completions.handleChatCompletions)(ctx, plugin));
|
|
120
128
|
logRequest(
|
|
121
129
|
ctx,
|
|
122
130
|
requestId,
|
|
123
131
|
model,
|
|
124
|
-
((
|
|
132
|
+
((_b = ctx.state.aiApiStreamResult) == null ? void 0 : _b.succeeded) === false ? "error" : "ok",
|
|
125
133
|
Date.now() - t0
|
|
126
134
|
);
|
|
127
135
|
return;
|
|
@@ -132,7 +140,7 @@ function createAiLlmRouter(plugin) {
|
|
|
132
140
|
return;
|
|
133
141
|
}
|
|
134
142
|
if (method === "POST" && subPath === "/completions") {
|
|
135
|
-
const completionsMode =
|
|
143
|
+
const completionsMode = resolvedMode;
|
|
136
144
|
if (completionsMode === "agent") {
|
|
137
145
|
const reqBody = ctx.request.body;
|
|
138
146
|
if ((reqBody == null ? void 0 : reqBody.prompt) !== void 0) {
|
|
@@ -147,7 +155,7 @@ function createAiLlmRouter(plugin) {
|
|
|
147
155
|
ctx,
|
|
148
156
|
requestId,
|
|
149
157
|
model,
|
|
150
|
-
((
|
|
158
|
+
((_c = ctx.state.aiApiStreamResult) == null ? void 0 : _c.succeeded) === false ? "error" : "ok",
|
|
151
159
|
Date.now() - t0
|
|
152
160
|
);
|
|
153
161
|
return;
|
package/dist/server/usage.js
CHANGED
|
@@ -30,7 +30,20 @@ __export(usage_exports, {
|
|
|
30
30
|
startUsageRecord: () => startUsageRecord
|
|
31
31
|
});
|
|
32
32
|
module.exports = __toCommonJS(usage_exports);
|
|
33
|
-
|
|
33
|
+
function normalizeUsage(value) {
|
|
34
|
+
if (!value || typeof value !== "object") return void 0;
|
|
35
|
+
const source = value;
|
|
36
|
+
const prompt = source.prompt_tokens ?? source.input_tokens;
|
|
37
|
+
const completion = source.completion_tokens ?? source.output_tokens;
|
|
38
|
+
const total = source.total_tokens;
|
|
39
|
+
if (prompt == null && completion == null && total == null) return void 0;
|
|
40
|
+
return {
|
|
41
|
+
prompt_tokens: typeof prompt === "number" ? prompt : null,
|
|
42
|
+
completion_tokens: typeof completion === "number" ? completion : null,
|
|
43
|
+
total_tokens: typeof total === "number" ? total : null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function startUsageRecord(ctx, requestId, endpoint, model, streaming, mode) {
|
|
34
47
|
var _a, _b;
|
|
35
48
|
const body = ctx.request.body || {};
|
|
36
49
|
const messages = Array.isArray(body.messages) ? body.messages : void 0;
|
|
@@ -45,7 +58,7 @@ async function startUsageRecord(ctx, requestId, endpoint, model, streaming) {
|
|
|
45
58
|
oauthSubject: oauth == null ? void 0 : oauth.subject,
|
|
46
59
|
oauthScopes: oauth == null ? void 0 : oauth.scopes,
|
|
47
60
|
endpoint,
|
|
48
|
-
mode
|
|
61
|
+
mode,
|
|
49
62
|
model: model === "-" ? void 0 : model,
|
|
50
63
|
status: "pending",
|
|
51
64
|
streaming,
|
|
@@ -56,21 +69,21 @@ async function startUsageRecord(ctx, requestId, endpoint, model, streaming) {
|
|
|
56
69
|
return record.id;
|
|
57
70
|
}
|
|
58
71
|
async function finishUsageRecord(ctx, id, startedAt, status) {
|
|
59
|
-
var _a;
|
|
72
|
+
var _a, _b;
|
|
60
73
|
const response = ctx.body || {};
|
|
61
74
|
const streamResult = ctx.state.aiApiStreamResult;
|
|
62
|
-
const usage = response.usage || (streamResult == null ? void 0 : streamResult.usage);
|
|
75
|
+
const usage = normalizeUsage(response.usage) || normalizeUsage((_a = response.response_metadata) == null ? void 0 : _a.usage) || normalizeUsage(streamResult == null ? void 0 : streamResult.usage);
|
|
63
76
|
const values = {
|
|
64
77
|
status: streamResult ? streamResult.succeeded ? "succeeded" : "failed" : status,
|
|
65
78
|
httpStatus: ctx.status,
|
|
66
|
-
errorCode: ((
|
|
79
|
+
errorCode: ((_b = response.error) == null ? void 0 : _b.code) || (streamResult == null ? void 0 : streamResult.errorCode),
|
|
67
80
|
inputTokens: usage == null ? void 0 : usage.prompt_tokens,
|
|
68
81
|
outputTokens: usage == null ? void 0 : usage.completion_tokens,
|
|
69
82
|
totalTokens: usage == null ? void 0 : usage.total_tokens,
|
|
70
83
|
providerRequestId: response.id || (streamResult == null ? void 0 : streamResult.id),
|
|
71
84
|
completedAt: /* @__PURE__ */ new Date(),
|
|
72
85
|
durationMs: Date.now() - startedAt,
|
|
73
|
-
responseMetadata: { usageSource: usage ? "
|
|
86
|
+
responseMetadata: { usageSource: usage ? "provider" : "unavailable" }
|
|
74
87
|
};
|
|
75
88
|
await ctx.db.getRepository("aiApiUsageRecords").update({ filterByTk: id, values });
|
|
76
89
|
}
|
|
@@ -98,7 +98,7 @@ function toOpenAIResponse(options) {
|
|
|
98
98
|
finish_reason: finishReason
|
|
99
99
|
}
|
|
100
100
|
],
|
|
101
|
-
usage: usage || { prompt_tokens:
|
|
101
|
+
usage: usage || { prompt_tokens: null, completion_tokens: null, total_tokens: null }
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
function toOpenAIStreamChunk(options) {
|
|
@@ -120,7 +120,7 @@ function toOpenAIStreamChunk(options) {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
function toOpenAIEmbeddingsResponse(options) {
|
|
123
|
-
const { model, embeddings, promptTokens =
|
|
123
|
+
const { model, embeddings, promptTokens = null } = options;
|
|
124
124
|
return {
|
|
125
125
|
object: "list",
|
|
126
126
|
data: embeddings.map((embedding, index) => ({
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toOpenAIResponse, toOpenAIStreamChunk } from '../utils/openai-format';
|
|
2
2
|
import { isStreamingRequested } from '../utils/streaming';
|
|
3
|
+
import { applyProviderRequestParameters, getProviderRequestParameters } from '../routes/chat-completions';
|
|
3
4
|
|
|
4
5
|
describe('AI API OpenAI tool-call formatting', () => {
|
|
5
6
|
it('streams by default and only disables streaming for an explicit false value', () => {
|
|
@@ -50,3 +51,58 @@ describe('AI API OpenAI tool-call formatting', () => {
|
|
|
50
51
|
expect(chunk.choices[0].delta.tool_calls?.[0].function?.name).toBe('get_weather');
|
|
51
52
|
});
|
|
52
53
|
});
|
|
54
|
+
|
|
55
|
+
describe('AI API provider parameter forwarding', () => {
|
|
56
|
+
it('forwards model and tool-call parameters not managed by the gateway', () => {
|
|
57
|
+
const parameters = getProviderRequestParameters({
|
|
58
|
+
model: 'service/model',
|
|
59
|
+
messages: [{ role: 'user', content: 'Use a tool' }],
|
|
60
|
+
tools: [{ type: 'function', function: { name: 'search' } }],
|
|
61
|
+
tool_choice: 'auto',
|
|
62
|
+
stream: true,
|
|
63
|
+
n: 1,
|
|
64
|
+
parallel_tool_calls: false,
|
|
65
|
+
reasoning_effort: 'medium',
|
|
66
|
+
max_completion_tokens: 4096,
|
|
67
|
+
seed: 7,
|
|
68
|
+
service_tier: 'default',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(parameters).toEqual({
|
|
72
|
+
parallel_tool_calls: false,
|
|
73
|
+
reasoning_effort: 'medium',
|
|
74
|
+
max_completion_tokens: 4096,
|
|
75
|
+
seed: 7,
|
|
76
|
+
service_tier: 'default',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('merges passthrough parameters into model kwargs and removes the synthetic text response format', () => {
|
|
81
|
+
const model = {
|
|
82
|
+
modelKwargs: {
|
|
83
|
+
response_format: { type: 'text' },
|
|
84
|
+
existing_provider_option: true,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
applyProviderRequestParameters(model, {
|
|
89
|
+
parallel_tool_calls: false,
|
|
90
|
+
reasoning_effort: 'high',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(model.modelKwargs).toEqual({
|
|
94
|
+
existing_provider_option: true,
|
|
95
|
+
parallel_tool_calls: false,
|
|
96
|
+
reasoning_effort: 'high',
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('preserves an explicitly requested response format', () => {
|
|
101
|
+
const model = { modelKwargs: { response_format: { type: 'text' } } };
|
|
102
|
+
const responseFormat = { type: 'json_schema', json_schema: { name: 'answer', schema: { type: 'object' } } };
|
|
103
|
+
|
|
104
|
+
applyProviderRequestParameters(model, { response_format: responseFormat });
|
|
105
|
+
|
|
106
|
+
expect(model.modelKwargs.response_format).toEqual(responseFormat);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -120,7 +120,8 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
120
120
|
return;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
const
|
|
123
|
+
const providerRequestParameters = getProviderRequestParameters(body);
|
|
124
|
+
const modelOptions: Record<string, unknown> = {
|
|
124
125
|
model: modelId,
|
|
125
126
|
llmService: service.name,
|
|
126
127
|
};
|
|
@@ -128,7 +129,8 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
128
129
|
// Pass through optional parameters
|
|
129
130
|
if (body.temperature !== undefined) modelOptions.temperature = body.temperature;
|
|
130
131
|
if (body.top_p !== undefined) modelOptions.topP = body.top_p;
|
|
131
|
-
if (body.
|
|
132
|
+
if (body.max_completion_tokens !== undefined) modelOptions.maxTokens = body.max_completion_tokens;
|
|
133
|
+
else if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
|
|
132
134
|
if (body.frequency_penalty !== undefined) modelOptions.frequencyPenalty = body.frequency_penalty;
|
|
133
135
|
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
134
136
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
@@ -192,14 +194,29 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
192
194
|
|
|
193
195
|
const completionId = generateCompletionId();
|
|
194
196
|
const baseModel = provider.createModel();
|
|
195
|
-
|
|
197
|
+
applyProviderRequestParameters(baseModel, providerRequestParameters);
|
|
198
|
+
const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice, providerRequestParameters);
|
|
196
199
|
|
|
197
200
|
if (stream) {
|
|
198
201
|
// ─── Streaming mode ───
|
|
199
|
-
await handleStreamingCompletion(
|
|
202
|
+
await handleStreamingCompletion(
|
|
203
|
+
ctx,
|
|
204
|
+
chatModel,
|
|
205
|
+
langchainMessages,
|
|
206
|
+
completionId,
|
|
207
|
+
body.model,
|
|
208
|
+
providerRequestParameters,
|
|
209
|
+
);
|
|
200
210
|
} else {
|
|
201
211
|
// ─── Non-streaming mode ───
|
|
202
|
-
await handleNonStreamingCompletion(
|
|
212
|
+
await handleNonStreamingCompletion(
|
|
213
|
+
ctx,
|
|
214
|
+
chatModel,
|
|
215
|
+
langchainMessages,
|
|
216
|
+
completionId,
|
|
217
|
+
body.model,
|
|
218
|
+
providerRequestParameters,
|
|
219
|
+
);
|
|
203
220
|
}
|
|
204
221
|
} catch (err) {
|
|
205
222
|
ctx.log.error('AI API chat completions error:', err);
|
|
@@ -218,8 +235,9 @@ async function handleNonStreamingCompletion(
|
|
|
218
235
|
messages: any[],
|
|
219
236
|
completionId: string,
|
|
220
237
|
modelName: string,
|
|
238
|
+
providerRequestParameters: Record<string, unknown>,
|
|
221
239
|
) {
|
|
222
|
-
const result = await chatModel.invoke(messages);
|
|
240
|
+
const result = await chatModel.invoke(messages, providerRequestParameters);
|
|
223
241
|
|
|
224
242
|
let content = '';
|
|
225
243
|
if (typeof result.content === 'string') {
|
|
@@ -258,6 +276,7 @@ async function handleStreamingCompletion(
|
|
|
258
276
|
messages: any[],
|
|
259
277
|
completionId: string,
|
|
260
278
|
modelName: string,
|
|
279
|
+
providerRequestParameters: Record<string, unknown>,
|
|
261
280
|
) {
|
|
262
281
|
// Set SSE headers
|
|
263
282
|
ctx.set({
|
|
@@ -284,7 +303,7 @@ async function handleStreamingCompletion(
|
|
|
284
303
|
let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined;
|
|
285
304
|
let finishReason = 'stop';
|
|
286
305
|
try {
|
|
287
|
-
const stream = await chatModel.stream(messages, { signal: requestAbort.signal });
|
|
306
|
+
const stream = await chatModel.stream(messages, { ...providerRequestParameters, signal: requestAbort.signal });
|
|
288
307
|
|
|
289
308
|
for await (const chunk of stream) {
|
|
290
309
|
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
@@ -367,12 +386,62 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
367
386
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
368
387
|
}
|
|
369
388
|
|
|
370
|
-
|
|
389
|
+
const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'tools', 'tool_choice', 'stream', 'n']);
|
|
390
|
+
|
|
391
|
+
export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
|
|
392
|
+
return Object.fromEntries(
|
|
393
|
+
Object.entries(body).filter(([name, value]) => !GATEWAY_MANAGED_PARAMETERS.has(name) && value !== undefined),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface ModelWithKwargs {
|
|
398
|
+
modelKwargs?: Record<string, unknown>;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function applyProviderRequestParameters(chatModel: unknown, parameters: Record<string, unknown>): void {
|
|
402
|
+
if (!chatModel || typeof chatModel !== 'object') return;
|
|
403
|
+
|
|
404
|
+
const model = chatModel as ModelWithKwargs;
|
|
405
|
+
const modelKwargs = { ...(model.modelKwargs ?? {}) };
|
|
406
|
+
|
|
407
|
+
// OpenAI providers currently install a synthetic text response format even when
|
|
408
|
+
// the client did not request one. Remove that default so LLM mode matches the
|
|
409
|
+
// original OpenAI-compatible request more closely.
|
|
410
|
+
if (!Object.hasOwn(parameters, 'response_format')) {
|
|
411
|
+
if (isDefaultTextResponseFormat(modelKwargs.response_format)) delete modelKwargs.response_format;
|
|
412
|
+
if (isDefaultResponsesTextFormat(modelKwargs.text)) delete modelKwargs.text;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
model.modelKwargs = { ...modelKwargs, ...parameters };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function bindRequestTools(
|
|
419
|
+
chatModel: any,
|
|
420
|
+
tools: unknown,
|
|
421
|
+
toolChoice: unknown,
|
|
422
|
+
providerRequestParameters: Record<string, unknown>,
|
|
423
|
+
) {
|
|
371
424
|
if (!Array.isArray(tools) || tools.length === 0) return chatModel;
|
|
372
425
|
if (typeof chatModel.bindTools !== 'function') {
|
|
373
426
|
throw new Error('The selected LLM provider does not support tool calling');
|
|
374
427
|
}
|
|
375
|
-
return chatModel.bindTools(tools,
|
|
428
|
+
return chatModel.bindTools(tools, {
|
|
429
|
+
...providerRequestParameters,
|
|
430
|
+
...(toolChoice === undefined ? {} : { tool_choice: toolChoice }),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function isDefaultTextResponseFormat(value: unknown): boolean {
|
|
435
|
+
return isRecord(value) && value.type === 'text' && Object.keys(value).length === 1;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isDefaultResponsesTextFormat(value: unknown): boolean {
|
|
439
|
+
if (!isRecord(value) || !isRecord(value.format)) return false;
|
|
440
|
+
return value.format.type === 'text' && Object.keys(value.format).length === 1 && Object.keys(value).length === 1;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
444
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
376
445
|
}
|
|
377
446
|
|
|
378
447
|
function normalizeToolCalls(value: unknown): OpenAIToolCall[] | undefined {
|
|
@@ -1,191 +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:
|
|
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 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: null,
|
|
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
|
+
}
|
|
@@ -19,6 +19,7 @@ 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';
|
|
@@ -120,16 +121,27 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
120
121
|
|
|
121
122
|
// ─── Route matching ───────────────────────────────────────────────────
|
|
122
123
|
const model = (ctx.request.body as any)?.model ?? '-';
|
|
124
|
+
const isUsageEndpoint =
|
|
125
|
+
method === 'POST' && (subPath === '/chat/completions' || subPath === '/completions' || subPath === '/embeddings');
|
|
126
|
+
const resolvedMode = isUsageEndpoint ? await resolveMode(ctx) : 'llm';
|
|
127
|
+
const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
|
|
128
|
+
const streaming = isUsageEndpoint && isStreamingRequested(requestBody.stream);
|
|
129
|
+
if (streaming && (subPath === '/chat/completions' || subPath === '/completions')) {
|
|
130
|
+
const streamOptions = requestBody.stream_options;
|
|
131
|
+
ctx.request.body = {
|
|
132
|
+
...requestBody,
|
|
133
|
+
stream_options: {
|
|
134
|
+
...(streamOptions && typeof streamOptions === 'object' ? streamOptions : {}),
|
|
135
|
+
include_usage: true,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
123
139
|
const t0 = Date.now();
|
|
124
140
|
let usageId: unknown;
|
|
125
141
|
try {
|
|
126
|
-
usageId =
|
|
127
|
-
ctx,
|
|
128
|
-
|
|
129
|
-
subPath,
|
|
130
|
-
String(model),
|
|
131
|
-
Boolean((ctx.request.body as any)?.stream),
|
|
132
|
-
);
|
|
142
|
+
usageId = isUsageEndpoint
|
|
143
|
+
? await startUsageRecord(ctx, requestId, subPath, String(model), streaming, resolvedMode)
|
|
144
|
+
: undefined;
|
|
133
145
|
} catch (usageError) {
|
|
134
146
|
ctx.log.error('AI API usage record could not be created:', usageError);
|
|
135
147
|
}
|
|
@@ -137,8 +149,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
137
149
|
try {
|
|
138
150
|
// POST /v1/chat/completions — route based on mode
|
|
139
151
|
if (method === 'POST' && subPath === '/chat/completions') {
|
|
140
|
-
|
|
141
|
-
await (mode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
|
|
152
|
+
await (resolvedMode === 'agent' ? handleAgentCompletions(ctx, plugin) : handleChatCompletions(ctx, plugin));
|
|
142
153
|
logRequest(
|
|
143
154
|
ctx,
|
|
144
155
|
requestId,
|
|
@@ -158,7 +169,7 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
158
169
|
|
|
159
170
|
// POST /v1/completions (legacy text completions — used by LiteLLM)
|
|
160
171
|
if (method === 'POST' && subPath === '/completions') {
|
|
161
|
-
const completionsMode =
|
|
172
|
+
const completionsMode = resolvedMode;
|
|
162
173
|
if (completionsMode === 'agent') {
|
|
163
174
|
// Convert legacy prompt → messages format for agent handler
|
|
164
175
|
const reqBody = ctx.request.body as any;
|
package/src/server/usage.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { Context } from '@nocobase/actions';
|
|
2
2
|
|
|
3
|
-
type Usage = { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
3
|
+
export type Usage = { prompt_tokens?: number | null; completion_tokens?: number | null; total_tokens?: number | null };
|
|
4
|
+
|
|
5
|
+
function normalizeUsage(value: unknown): Usage | undefined {
|
|
6
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
7
|
+
const source = value as Record<string, unknown>;
|
|
8
|
+
const prompt = source.prompt_tokens ?? source.input_tokens;
|
|
9
|
+
const completion = source.completion_tokens ?? source.output_tokens;
|
|
10
|
+
const total = source.total_tokens;
|
|
11
|
+
if (prompt == null && completion == null && total == null) return undefined;
|
|
12
|
+
return {
|
|
13
|
+
prompt_tokens: typeof prompt === 'number' ? prompt : null,
|
|
14
|
+
completion_tokens: typeof completion === 'number' ? completion : null,
|
|
15
|
+
total_tokens: typeof total === 'number' ? total : null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
4
18
|
|
|
5
19
|
export async function startUsageRecord(
|
|
6
20
|
ctx: Context,
|
|
@@ -8,6 +22,7 @@ export async function startUsageRecord(
|
|
|
8
22
|
endpoint: string,
|
|
9
23
|
model: string,
|
|
10
24
|
streaming: boolean,
|
|
25
|
+
mode: 'llm' | 'agent',
|
|
11
26
|
) {
|
|
12
27
|
const body = (ctx.request.body || {}) as Record<string, unknown>;
|
|
13
28
|
const messages = Array.isArray(body.messages) ? body.messages : undefined;
|
|
@@ -22,7 +37,7 @@ export async function startUsageRecord(
|
|
|
22
37
|
oauthSubject: oauth?.subject,
|
|
23
38
|
oauthScopes: oauth?.scopes,
|
|
24
39
|
endpoint,
|
|
25
|
-
mode
|
|
40
|
+
mode,
|
|
26
41
|
model: model === '-' ? undefined : model,
|
|
27
42
|
status: 'pending',
|
|
28
43
|
streaming,
|
|
@@ -34,11 +49,19 @@ export async function startUsageRecord(
|
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: number, status: 'succeeded' | 'failed') {
|
|
37
|
-
const response = (ctx.body || {}) as {
|
|
52
|
+
const response = (ctx.body || {}) as {
|
|
53
|
+
usage?: Usage;
|
|
54
|
+
response_metadata?: { usage?: unknown };
|
|
55
|
+
id?: string;
|
|
56
|
+
error?: { code?: string };
|
|
57
|
+
};
|
|
38
58
|
const streamResult = ctx.state.aiApiStreamResult as
|
|
39
59
|
| { usage?: Usage; id?: string; errorCode?: string; succeeded: boolean }
|
|
40
60
|
| undefined;
|
|
41
|
-
const usage =
|
|
61
|
+
const usage =
|
|
62
|
+
normalizeUsage(response.usage) ||
|
|
63
|
+
normalizeUsage(response.response_metadata?.usage) ||
|
|
64
|
+
normalizeUsage(streamResult?.usage);
|
|
42
65
|
const values = {
|
|
43
66
|
status: streamResult ? (streamResult.succeeded ? 'succeeded' : 'failed') : status,
|
|
44
67
|
httpStatus: ctx.status,
|
|
@@ -49,7 +72,7 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
|
|
|
49
72
|
providerRequestId: response.id || streamResult?.id,
|
|
50
73
|
completedAt: new Date(),
|
|
51
74
|
durationMs: Date.now() - startedAt,
|
|
52
|
-
responseMetadata: { usageSource: usage ? '
|
|
75
|
+
responseMetadata: { usageSource: usage ? 'provider' : 'unavailable' },
|
|
53
76
|
};
|
|
54
77
|
await ctx.db.getRepository('aiApiUsageRecords').update({ filterByTk: id, values });
|
|
55
78
|
}
|
|
@@ -87,7 +87,7 @@ export function toOpenAIResponse(options: {
|
|
|
87
87
|
finish_reason: finishReason,
|
|
88
88
|
},
|
|
89
89
|
],
|
|
90
|
-
usage: usage || { prompt_tokens:
|
|
90
|
+
usage: usage || { prompt_tokens: null, completion_tokens: null, total_tokens: null },
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -132,8 +132,12 @@ export interface OpenAIToolCallChunk {
|
|
|
132
132
|
|
|
133
133
|
// ─── OpenAI Embeddings response ───
|
|
134
134
|
|
|
135
|
-
export function toOpenAIEmbeddingsResponse(options: {
|
|
136
|
-
|
|
135
|
+
export function toOpenAIEmbeddingsResponse(options: {
|
|
136
|
+
model: string;
|
|
137
|
+
embeddings: number[][];
|
|
138
|
+
promptTokens?: number | null;
|
|
139
|
+
}) {
|
|
140
|
+
const { model, embeddings, promptTokens = null } = options;
|
|
137
141
|
return {
|
|
138
142
|
object: 'list' as const,
|
|
139
143
|
data: embeddings.map((embedding, index) => ({
|