plugin-ai-api 1.0.12 → 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.
@@ -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: 0
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, _d;
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
- const mode = await resolveMode(ctx);
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
- ((_c = ctx.state.aiApiStreamResult) == null ? void 0 : _c.succeeded) === false ? "error" : "ok",
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 = await resolveMode(ctx);
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
- ((_d = ctx.state.aiApiStreamResult) == null ? void 0 : _d.succeeded) === false ? "error" : "ok",
158
+ ((_c = ctx.state.aiApiStreamResult) == null ? void 0 : _c.succeeded) === false ? "error" : "ok",
151
159
  Date.now() - t0
152
160
  );
153
161
  return;
@@ -30,7 +30,20 @@ __export(usage_exports, {
30
30
  startUsageRecord: () => startUsageRecord
31
31
  });
32
32
  module.exports = __toCommonJS(usage_exports);
33
- async function startUsageRecord(ctx, requestId, endpoint, model, streaming) {
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: ctx.get("X-AI-Mode") || void 0,
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: ((_a = response.error) == null ? void 0 : _a.code) || (streamResult == null ? void 0 : streamResult.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 ? "response" : "unavailable" }
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: 0, completion_tokens: 0, total_tokens: 0 }
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 = 0 } = options;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {
@@ -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: 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 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 = await startUsageRecord(
127
- ctx,
128
- requestId,
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
- const mode = await resolveMode(ctx);
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 = await resolveMode(ctx);
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;
@@ -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: ctx.get('X-AI-Mode') || undefined,
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 { usage?: Usage; id?: string; error?: { code?: string } };
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 = response.usage || streamResult?.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 ? 'response' : 'unavailable' },
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: 0, completion_tokens: 0, total_tokens: 0 },
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: { model: string; embeddings: number[][]; promptTokens?: number }) {
136
- const { model, embeddings, promptTokens = 0 } = options;
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) => ({