plugin-ai-api 1.0.24 → 1.0.25
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/client/757.56952e321dc399b7.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +1 -0
- package/dist/locale/vi-VN.json +1 -0
- package/dist/locale/zh-CN.json +1 -0
- package/dist/server/billing.js +6 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- package/dist/server/plugin.js +10 -0
- package/dist/server/resource/ai-api-config.js +5 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/chat-completions.js +89 -10
- package/dist/server/routes/completions.js +32 -10
- package/dist/server/services/file-processor.js +262 -0
- package/dist/server/usage.js +33 -3
- package/dist/server/utils/direct-llm-context.js +150 -15
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/swagger.js +42 -3
- package/package.json +1 -1
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/locale/en-US.json +1 -0
- package/src/locale/vi-VN.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/server/__tests__/direct-llm-context.test.ts +87 -6
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/request-body.test.ts +45 -2
- package/src/server/__tests__/usage-route.test.ts +120 -3
- package/src/server/__tests__/usage.test.ts +19 -0
- package/src/server/billing.ts +6 -1
- package/src/server/collections/ai-api-config.ts +8 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +70 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- package/src/server/plugin.ts +20 -0
- package/src/server/resource/ai-api-config.ts +5 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/chat-completions.ts +134 -11
- package/src/server/routes/completions.ts +33 -7
- package/src/server/services/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +323 -0
- package/src/server/usage.ts +47 -1
- package/src/server/utils/direct-llm-context.ts +198 -20
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/resolve-service.ts +82 -82
- package/src/swagger.ts +45 -3
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
package/dist/swagger.js
CHANGED
|
@@ -285,6 +285,11 @@ var swagger_default = {
|
|
|
285
285
|
maximum: 100,
|
|
286
286
|
default: 10,
|
|
287
287
|
description: "Max request body size in megabytes. Requests above this return 413. The gateway buffers each body in memory, so values above 100 are rejected."
|
|
288
|
+
},
|
|
289
|
+
pdfRenderPagesAsImages: {
|
|
290
|
+
type: "boolean",
|
|
291
|
+
default: false,
|
|
292
|
+
description: "When true, PDF file/file_url blocks are rendered to per-page PNG images and sent as image_url blocks. Requires a registered PdfToImageRenderer. When false or no renderer is available, PDFs are forwarded as file blocks."
|
|
288
293
|
}
|
|
289
294
|
}
|
|
290
295
|
},
|
|
@@ -299,9 +304,9 @@ var swagger_default = {
|
|
|
299
304
|
},
|
|
300
305
|
ContentBlock: {
|
|
301
306
|
type: "object",
|
|
302
|
-
description: "A multimodal content block.
|
|
307
|
+
description: "A multimodal content block. text, image_url, file and file_url blocks are forwarded; file and file_url blocks are first run through the configurable file processor service.",
|
|
303
308
|
properties: {
|
|
304
|
-
type: { type: "string", enum: ["text", "image_url"] },
|
|
309
|
+
type: { type: "string", enum: ["text", "image_url", "file", "file_url"] },
|
|
305
310
|
text: { type: "string" },
|
|
306
311
|
image_url: {
|
|
307
312
|
type: "object",
|
|
@@ -314,6 +319,34 @@ var swagger_default = {
|
|
|
314
319
|
detail: { type: "string", enum: ["auto", "low", "high"] }
|
|
315
320
|
},
|
|
316
321
|
required: ["url"]
|
|
322
|
+
},
|
|
323
|
+
file: {
|
|
324
|
+
type: "object",
|
|
325
|
+
properties: {
|
|
326
|
+
file_data: {
|
|
327
|
+
type: "string",
|
|
328
|
+
description: "A base64 data URL, e.g. data:application/pdf;base64,JVBERi0...",
|
|
329
|
+
example: "data:application/pdf;base64,JVBERi0..."
|
|
330
|
+
},
|
|
331
|
+
filename: { type: "string" },
|
|
332
|
+
mime_type: {
|
|
333
|
+
type: "string",
|
|
334
|
+
description: "MIME type of the file, e.g. application/pdf",
|
|
335
|
+
example: "application/pdf"
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
required: ["file_data"]
|
|
339
|
+
},
|
|
340
|
+
file_url: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
url: {
|
|
344
|
+
type: "string",
|
|
345
|
+
description: "An http(s) URL pointing to a file. The gateway downloads the file and converts it to a file block.",
|
|
346
|
+
example: "https://example.com/document.pdf"
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
required: ["url"]
|
|
317
350
|
}
|
|
318
351
|
},
|
|
319
352
|
required: ["type"]
|
|
@@ -374,7 +407,13 @@ var swagger_default = {
|
|
|
374
407
|
properties: {
|
|
375
408
|
prompt_tokens: { type: "integer" },
|
|
376
409
|
completion_tokens: { type: "integer" },
|
|
377
|
-
total_tokens: { type: "integer" }
|
|
410
|
+
total_tokens: { type: "integer" },
|
|
411
|
+
prompt_tokens_details: {
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: {
|
|
414
|
+
cached_tokens: { type: "integer" }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
378
417
|
}
|
|
379
418
|
}
|
|
380
419
|
}
|
package/package.json
CHANGED
|
@@ -32,6 +32,7 @@ interface UsageRecord {
|
|
|
32
32
|
inputTokens?: number;
|
|
33
33
|
outputTokens?: number;
|
|
34
34
|
totalTokens?: number;
|
|
35
|
+
promptCacheTokens?: number;
|
|
35
36
|
estimatedCost?: string;
|
|
36
37
|
currency?: string;
|
|
37
38
|
costStatus?: string;
|
|
@@ -51,6 +52,7 @@ interface UsageSummary {
|
|
|
51
52
|
inputTokens: number;
|
|
52
53
|
outputTokens: number;
|
|
53
54
|
totalTokens: number;
|
|
55
|
+
promptCacheTokens: number;
|
|
54
56
|
costsByCurrency: { currency: string; totalCost: string }[];
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -65,6 +67,7 @@ const emptySummary: UsageSummary = {
|
|
|
65
67
|
inputTokens: 0,
|
|
66
68
|
outputTokens: 0,
|
|
67
69
|
totalTokens: 0,
|
|
70
|
+
promptCacheTokens: 0,
|
|
68
71
|
costsByCurrency: [],
|
|
69
72
|
};
|
|
70
73
|
|
|
@@ -153,6 +156,7 @@ export default function UsagePage() {
|
|
|
153
156
|
{ title: t('Input tokens'), dataIndex: 'inputTokens', key: 'inputTokens', width: 110 },
|
|
154
157
|
{ title: t('Output tokens'), dataIndex: 'outputTokens', key: 'outputTokens', width: 110 },
|
|
155
158
|
{ title: t('Total tokens'), dataIndex: 'totalTokens', key: 'totalTokens', width: 110 },
|
|
159
|
+
{ title: t('Prompt cache tokens'), dataIndex: 'promptCacheTokens', key: 'promptCacheTokens', width: 140 },
|
|
156
160
|
{
|
|
157
161
|
title: t('Cost'),
|
|
158
162
|
key: 'cost',
|
|
@@ -234,6 +238,11 @@ export default function UsagePage() {
|
|
|
234
238
|
<Statistic title={t('Total tokens')} value={summary.totalTokens} />
|
|
235
239
|
</Card>
|
|
236
240
|
</Col>
|
|
241
|
+
<Col xs={24} sm={12} lg={6}>
|
|
242
|
+
<Card size="small">
|
|
243
|
+
<Statistic title={t('Prompt cache tokens')} value={summary.promptCacheTokens} />
|
|
244
|
+
</Card>
|
|
245
|
+
</Col>
|
|
237
246
|
<Col xs={24}>
|
|
238
247
|
<Card size="small">
|
|
239
248
|
<Statistic title={t('Total cost')} value={totalCost} />
|
package/src/locale/en-US.json
CHANGED
package/src/locale/vi-VN.json
CHANGED
package/src/locale/zh-CN.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Context } from '@nocobase/actions';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DirectLlmContextError,
|
|
5
|
+
prepareDirectLlmContext,
|
|
6
|
+
type OpenAIMessage,
|
|
7
|
+
parseImageDimensions,
|
|
8
|
+
} from '../utils/direct-llm-context';
|
|
4
9
|
|
|
5
10
|
function context({ behavior = 'reject', metadata = { contextWindow: 120, maxCompletionTokens: 40 } } = {}): Context {
|
|
6
11
|
return {
|
|
@@ -34,6 +39,25 @@ function request(messages: OpenAIMessage[], tools?: unknown) {
|
|
|
34
39
|
};
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
// A 1x1 PNG encoded as a base64 data URL.
|
|
43
|
+
const ONE_PIXEL_PNG =
|
|
44
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
|
45
|
+
|
|
46
|
+
// A tiny valid base64 PDF payload (PDF header + minimal content).
|
|
47
|
+
const TINY_PDF_BASE64 = 'data:application/pdf;base64,JVBERi0xLjAKPDwKPiEKZW5kb2JqCmVuZG9iagpl';
|
|
48
|
+
|
|
49
|
+
// A PNG header with 1280x720 dimensions. The pixel data is truncated/invalid,
|
|
50
|
+
// but the header is valid enough for dimension parsing to succeed.
|
|
51
|
+
const LARGE_PNG_HEADER_BASE64 =
|
|
52
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABQAAAALQCAYAAADPfd1WAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoSAA';
|
|
53
|
+
|
|
54
|
+
// The fixed conservative estimate used for http(s) image URLs.
|
|
55
|
+
const VISION_HTTP_URL_ESTIMATE = 1024;
|
|
56
|
+
|
|
57
|
+
function largeContext(): Context {
|
|
58
|
+
return context({ metadata: { contextWindow: 2000, maxCompletionTokens: 40 } });
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
describe('direct LLM context preparation', () => {
|
|
38
62
|
it('uses reject when the user has no enabled policy', async () => {
|
|
39
63
|
const ctx = context();
|
|
@@ -100,15 +124,57 @@ describe('direct LLM context preparation', () => {
|
|
|
100
124
|
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
|
|
101
125
|
});
|
|
102
126
|
|
|
103
|
-
it('
|
|
127
|
+
it('estimates vision tokens for a base64 image_url and allows small payloads', async () => {
|
|
128
|
+
const prepared = await prepareDirectLlmContext(
|
|
129
|
+
largeContext(),
|
|
130
|
+
request([{ role: 'user', content: [{ type: 'image_url', image_url: { url: ONE_PIXEL_PNG } }] }]),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(prepared.estimatedInputTokens).toBeGreaterThan(0);
|
|
134
|
+
expect(prepared.truncated).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('estimates a fixed conservative token count for http(s) image_url URLs', async () => {
|
|
138
|
+
const prepared = await prepareDirectLlmContext(
|
|
139
|
+
largeContext(),
|
|
140
|
+
request([
|
|
141
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] },
|
|
142
|
+
]),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(prepared.estimatedInputTokens).toBeGreaterThanOrEqual(VISION_HTTP_URL_ESTIMATE);
|
|
146
|
+
expect(prepared.truncated).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('rejects a base64 image that exceeds the input budget', async () => {
|
|
150
|
+
// Large header claims 1000x1000, so vision estimate is 85 + 4 * 170 = 765 tokens,
|
|
151
|
+
// which easily exceeds the 80 token budget of the default test context.
|
|
104
152
|
await expect(
|
|
105
153
|
prepareDirectLlmContext(
|
|
106
154
|
context(),
|
|
107
|
-
request([
|
|
108
|
-
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.test/image.png' } }] },
|
|
109
|
-
]),
|
|
155
|
+
request([{ role: 'user', content: [{ type: 'image_url', image_url: { url: LARGE_PNG_HEADER_BASE64 } }] }]),
|
|
110
156
|
),
|
|
111
|
-
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: '
|
|
157
|
+
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('estimates file block tokens from decoded base64 size', async () => {
|
|
161
|
+
const prepared = await prepareDirectLlmContext(
|
|
162
|
+
context(),
|
|
163
|
+
request([{ role: 'user', content: [{ type: 'file', file: { file_data: TINY_PDF_BASE64, filename: 'x.pdf' } }] }]),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(prepared.estimatedInputTokens).toBeGreaterThan(0);
|
|
167
|
+
expect(prepared.truncated).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects a base64 file that exceeds the input budget', async () => {
|
|
171
|
+
const largeBase64 = `data:application/pdf;base64,${Buffer.alloc(100_000).toString('base64')}`;
|
|
172
|
+
await expect(
|
|
173
|
+
prepareDirectLlmContext(
|
|
174
|
+
context(),
|
|
175
|
+
request([{ role: 'user', content: [{ type: 'file', file: { file_data: largeBase64, filename: 'x.pdf' } }] }]),
|
|
176
|
+
),
|
|
177
|
+
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
|
|
112
178
|
});
|
|
113
179
|
|
|
114
180
|
it('counts tool definitions as fixed input overhead', async () => {
|
|
@@ -123,3 +189,18 @@ describe('direct LLM context preparation', () => {
|
|
|
123
189
|
).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
|
|
124
190
|
});
|
|
125
191
|
});
|
|
192
|
+
|
|
193
|
+
describe('image dimension parsing', () => {
|
|
194
|
+
it('parses PNG dimensions', () => {
|
|
195
|
+
// The shared 1x1 test PNG is a valid PNG with dimensions 1x1.
|
|
196
|
+
const base64 = ONE_PIXEL_PNG.split(',')[1];
|
|
197
|
+
const png = Buffer.from(base64, 'base64');
|
|
198
|
+
expect(parseImageDimensions(png)).toEqual({ width: 1, height: 1 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('parses JPEG dimensions without reading past width/height', () => {
|
|
202
|
+
// Minimal JPEG SOF0 segment: height 1024, width 1024.
|
|
203
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x04, 0x00, 0x04, 0x00, 0x01, 0x22, 0x00]);
|
|
204
|
+
expect(parseImageDimensions(jpeg)).toEqual({ width: 1024, height: 1024 });
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -86,7 +86,12 @@ describe('AI API OpenAI usage-only streaming chunks', () => {
|
|
|
86
86
|
|
|
87
87
|
expect(chunk.object).toBe('chat.completion.chunk');
|
|
88
88
|
expect(chunk.choices).toEqual([]);
|
|
89
|
-
expect(chunk.usage).toEqual({
|
|
89
|
+
expect(chunk.usage).toEqual({
|
|
90
|
+
prompt_tokens: 8,
|
|
91
|
+
completion_tokens: 3,
|
|
92
|
+
total_tokens: 11,
|
|
93
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
94
|
+
});
|
|
90
95
|
});
|
|
91
96
|
|
|
92
97
|
it('formats a usage-only legacy text completion chunk', () => {
|
|
@@ -99,7 +104,12 @@ describe('AI API OpenAI usage-only streaming chunks', () => {
|
|
|
99
104
|
|
|
100
105
|
expect(chunk.object).toBe('text_completion');
|
|
101
106
|
expect(chunk.choices).toEqual([]);
|
|
102
|
-
expect(chunk.usage).toEqual({
|
|
107
|
+
expect(chunk.usage).toEqual({
|
|
108
|
+
prompt_tokens: 2,
|
|
109
|
+
completion_tokens: 5,
|
|
110
|
+
total_tokens: 7,
|
|
111
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
112
|
+
});
|
|
103
113
|
});
|
|
104
114
|
});
|
|
105
115
|
|
|
@@ -176,11 +176,54 @@ describe('AI API multimodal content block validation', () => {
|
|
|
176
176
|
it('rejects an unsupported block type and names it', () => {
|
|
177
177
|
const problem = findContentBlockProblem([
|
|
178
178
|
{ role: 'system', content: 'You are helpful.' },
|
|
179
|
-
{ role: 'user', content: [{ type: '
|
|
179
|
+
{ role: 'user', content: [{ type: 'audio', audio: { url: 'https://example.com/x.mp3' } }] },
|
|
180
180
|
]);
|
|
181
181
|
|
|
182
182
|
expect(problem?.index).toBe(1);
|
|
183
|
-
expect(problem?.reason).toContain("'
|
|
183
|
+
expect(problem?.reason).toContain("'audio' is not supported");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('accepts well-formed file and file_url blocks', () => {
|
|
187
|
+
expect(
|
|
188
|
+
findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } }])),
|
|
189
|
+
).toBeUndefined();
|
|
190
|
+
expect(
|
|
191
|
+
findContentBlockProblem(wrap([{ type: 'file_url', file_url: { url: 'https://example.com/doc.pdf' } }])),
|
|
192
|
+
).toBeUndefined();
|
|
193
|
+
// Complex MIME types with hyphens, dots, or '+' used to be rejected by the
|
|
194
|
+
// image_url grammar even though they are valid file attachments.
|
|
195
|
+
expect(
|
|
196
|
+
findContentBlockProblem(
|
|
197
|
+
wrap([
|
|
198
|
+
{
|
|
199
|
+
type: 'file',
|
|
200
|
+
file: {
|
|
201
|
+
file_data: 'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,JVBERi0=',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
]),
|
|
205
|
+
),
|
|
206
|
+
).toBeUndefined();
|
|
207
|
+
expect(
|
|
208
|
+
findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'data:image/svg+xml;base64,JVBERi0=' } }])),
|
|
209
|
+
).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('rejects a file block with missing or malformed file_data', () => {
|
|
213
|
+
expect(findContentBlockProblem(wrap([{ type: 'file' }]))?.reason).toContain("object 'file' field");
|
|
214
|
+
expect(findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'not-a-data-url' } }]))?.reason).toContain(
|
|
215
|
+
"'data:'",
|
|
216
|
+
);
|
|
217
|
+
expect(
|
|
218
|
+
findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'data:application/pdf;base64,!!!' } }]))?.reason,
|
|
219
|
+
).toContain('malformed base64');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('rejects a file_url block with missing or unsupported URL', () => {
|
|
223
|
+
expect(findContentBlockProblem(wrap([{ type: 'file_url' }]))?.reason).toContain("object 'file_url' field");
|
|
224
|
+
expect(
|
|
225
|
+
findContentBlockProblem(wrap([{ type: 'file_url', file_url: { url: 'ftp://example.com/doc.pdf' } }]))?.reason,
|
|
226
|
+
).toContain("protocol 'ftp:'");
|
|
184
227
|
});
|
|
185
228
|
|
|
186
229
|
it('rejects a text block with no text payload', () => {
|
|
@@ -205,6 +205,7 @@ describe('AI API chat usage collection', () => {
|
|
|
205
205
|
prompt_tokens: 0,
|
|
206
206
|
completion_tokens: 0,
|
|
207
207
|
total_tokens: 0,
|
|
208
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
208
209
|
});
|
|
209
210
|
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
210
211
|
source: 'unavailable',
|
|
@@ -229,6 +230,7 @@ describe('AI API chat usage collection', () => {
|
|
|
229
230
|
prompt_tokens: 8,
|
|
230
231
|
completion_tokens: 3,
|
|
231
232
|
total_tokens: 11,
|
|
233
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
232
234
|
});
|
|
233
235
|
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
234
236
|
source: 'provider',
|
|
@@ -238,6 +240,73 @@ describe('AI API chat usage collection', () => {
|
|
|
238
240
|
});
|
|
239
241
|
});
|
|
240
242
|
|
|
243
|
+
it('extracts cached prompt tokens from OpenAI-style usage metadata', async () => {
|
|
244
|
+
const { ctx } = createContext({
|
|
245
|
+
content: 'Hello back',
|
|
246
|
+
usage_metadata: {
|
|
247
|
+
input_tokens: 8,
|
|
248
|
+
output_tokens: 3,
|
|
249
|
+
total_tokens: 11,
|
|
250
|
+
prompt_tokens_details: { cached_tokens: 7 },
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
255
|
+
|
|
256
|
+
expect((ctx.body as { usage: object }).usage).toEqual({
|
|
257
|
+
prompt_tokens: 8,
|
|
258
|
+
completion_tokens: 3,
|
|
259
|
+
total_tokens: 11,
|
|
260
|
+
prompt_tokens_details: { cached_tokens: 7 },
|
|
261
|
+
});
|
|
262
|
+
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
263
|
+
source: 'provider',
|
|
264
|
+
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11, prompt_cache_tokens: 7 },
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('falls back to response_metadata when usage metadata omits cached tokens', async () => {
|
|
269
|
+
const { ctx } = createContext({
|
|
270
|
+
content: 'Hello back',
|
|
271
|
+
usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
|
|
272
|
+
response_metadata: { usage: { prompt_tokens_details: { cached_tokens: 5 } } },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
276
|
+
|
|
277
|
+
expect((ctx.body as { usage: object }).usage).toEqual({
|
|
278
|
+
prompt_tokens: 8,
|
|
279
|
+
completion_tokens: 3,
|
|
280
|
+
total_tokens: 11,
|
|
281
|
+
prompt_tokens_details: { cached_tokens: 5 },
|
|
282
|
+
});
|
|
283
|
+
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
284
|
+
source: 'provider',
|
|
285
|
+
usage: { prompt_cache_tokens: 5 },
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('extracts cached prompt tokens from LangChain-style input_token_details', async () => {
|
|
290
|
+
const { ctx } = createContext({
|
|
291
|
+
content: 'Hello back',
|
|
292
|
+
usage_metadata: {
|
|
293
|
+
input_tokens: 8,
|
|
294
|
+
output_tokens: 3,
|
|
295
|
+
total_tokens: 11,
|
|
296
|
+
input_token_details: { cache_read: 4 },
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
301
|
+
|
|
302
|
+
expect((ctx.body as { usage: object }).usage).toEqual({
|
|
303
|
+
prompt_tokens: 8,
|
|
304
|
+
completion_tokens: 3,
|
|
305
|
+
total_tokens: 11,
|
|
306
|
+
prompt_tokens_details: { cached_tokens: 4 },
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
241
310
|
it('emits a usage-only chunk immediately before [DONE] for streaming chat completions', async () => {
|
|
242
311
|
const { ctx, writes } = createStreamingContext(
|
|
243
312
|
{
|
|
@@ -261,7 +330,12 @@ describe('AI API chat usage collection', () => {
|
|
|
261
330
|
|
|
262
331
|
expect(finishChunk.choices[0].finish_reason).toBe('stop');
|
|
263
332
|
expect(usageChunk.choices).toEqual([]);
|
|
264
|
-
expect(usageChunk.usage).toEqual({
|
|
333
|
+
expect(usageChunk.usage).toEqual({
|
|
334
|
+
prompt_tokens: 5,
|
|
335
|
+
completion_tokens: 4,
|
|
336
|
+
total_tokens: 9,
|
|
337
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
338
|
+
});
|
|
265
339
|
expect(usageChunk).toHaveProperty('usage.prompt_tokens', 5);
|
|
266
340
|
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
267
341
|
source: 'provider',
|
|
@@ -287,7 +361,12 @@ describe('AI API chat usage collection', () => {
|
|
|
287
361
|
|
|
288
362
|
expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
|
|
289
363
|
expect(usageChunk.choices).toEqual([]);
|
|
290
|
-
expect(usageChunk.usage).toEqual({
|
|
364
|
+
expect(usageChunk.usage).toEqual({
|
|
365
|
+
prompt_tokens: 5,
|
|
366
|
+
completion_tokens: 4,
|
|
367
|
+
total_tokens: 9,
|
|
368
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
369
|
+
});
|
|
291
370
|
expect(model.stream).toHaveBeenCalledWith(
|
|
292
371
|
expect.anything(),
|
|
293
372
|
expect.objectContaining({
|
|
@@ -316,7 +395,12 @@ describe('AI API chat usage collection', () => {
|
|
|
316
395
|
expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
|
|
317
396
|
expect(usageChunk.object).toBe('text_completion');
|
|
318
397
|
expect(usageChunk.choices).toEqual([]);
|
|
319
|
-
expect(usageChunk.usage).toEqual({
|
|
398
|
+
expect(usageChunk.usage).toEqual({
|
|
399
|
+
prompt_tokens: 2,
|
|
400
|
+
completion_tokens: 5,
|
|
401
|
+
total_tokens: 7,
|
|
402
|
+
prompt_tokens_details: { cached_tokens: null },
|
|
403
|
+
});
|
|
320
404
|
expect(model.stream).toHaveBeenCalledWith(
|
|
321
405
|
expect.anything(),
|
|
322
406
|
expect.objectContaining({
|
|
@@ -366,4 +450,37 @@ describe('AI API chat usage collection', () => {
|
|
|
366
450
|
expect(precedingChunk.usage).toBeNull();
|
|
367
451
|
expect(ctx.state.aiApiUsageResult).toMatchObject({ source: 'unavailable' });
|
|
368
452
|
});
|
|
453
|
+
|
|
454
|
+
it('forwards passthrough provider parameters in legacy non-stream completions', async () => {
|
|
455
|
+
const { ctx, model } = createContext({
|
|
456
|
+
content: 'Hello back',
|
|
457
|
+
usage_metadata: { input_tokens: 2, output_tokens: 5, total_tokens: 7 },
|
|
458
|
+
});
|
|
459
|
+
(ctx.request.body as Record<string, unknown>).prompt = 'Hello';
|
|
460
|
+
(ctx.request.body as Record<string, unknown>).seed = 42;
|
|
461
|
+
(ctx.request.body as Record<string, unknown>).reasoning_effort = 'medium';
|
|
462
|
+
|
|
463
|
+
await handleCompletions(ctx, {} as PluginAiApiServer);
|
|
464
|
+
|
|
465
|
+
expect(ctx.status).toBe(200);
|
|
466
|
+
expect(model.invoke).toHaveBeenCalledWith(
|
|
467
|
+
expect.anything(),
|
|
468
|
+
expect.objectContaining({ seed: 42, reasoning_effort: 'medium' }),
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('forwards passthrough provider parameters in legacy streaming completions', async () => {
|
|
473
|
+
const { ctx, model } = createStreamingContext(
|
|
474
|
+
{ content: 'Hi', usage_metadata: { input_tokens: 2, output_tokens: 5, total_tokens: 7 } },
|
|
475
|
+
{ include_usage: false, include_obfuscation: false },
|
|
476
|
+
{ prompt: 'Hello', seed: 42, reasoning_effort: 'medium' },
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
await handleCompletions(ctx, {} as PluginAiApiServer);
|
|
480
|
+
|
|
481
|
+
expect(model.stream).toHaveBeenCalledWith(
|
|
482
|
+
expect.anything(),
|
|
483
|
+
expect.objectContaining({ seed: 42, reasoning_effort: 'medium' }),
|
|
484
|
+
);
|
|
485
|
+
});
|
|
369
486
|
});
|
|
@@ -24,6 +24,7 @@ describe('AI API usage normalization', () => {
|
|
|
24
24
|
prompt_tokens: 12,
|
|
25
25
|
completion_tokens: 5,
|
|
26
26
|
total_tokens: 17,
|
|
27
|
+
prompt_cache_tokens: null,
|
|
27
28
|
});
|
|
28
29
|
});
|
|
29
30
|
|
|
@@ -32,6 +33,24 @@ describe('AI API usage normalization', () => {
|
|
|
32
33
|
prompt_tokens: 0,
|
|
33
34
|
completion_tokens: 0,
|
|
34
35
|
total_tokens: 0,
|
|
36
|
+
prompt_cache_tokens: null,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('extracts prompt_cache_tokens when present in various provider formats', () => {
|
|
41
|
+
expect(
|
|
42
|
+
normalizeUsage({ prompt_tokens: 10, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 8 } }),
|
|
43
|
+
).toEqual({
|
|
44
|
+
prompt_tokens: 10,
|
|
45
|
+
completion_tokens: 5,
|
|
46
|
+
total_tokens: 15,
|
|
47
|
+
prompt_cache_tokens: 8,
|
|
48
|
+
});
|
|
49
|
+
expect(normalizeUsage({ input_tokens: 20, output_tokens: 10, input_token_details: { cache_read: 15 } })).toEqual({
|
|
50
|
+
prompt_tokens: 20,
|
|
51
|
+
completion_tokens: 10,
|
|
52
|
+
total_tokens: 30,
|
|
53
|
+
prompt_cache_tokens: 15,
|
|
35
54
|
});
|
|
36
55
|
});
|
|
37
56
|
|
package/src/server/billing.ts
CHANGED
|
@@ -373,7 +373,12 @@ export async function finalizeLlmBilling(
|
|
|
373
373
|
|
|
374
374
|
return {
|
|
375
375
|
usage: numbers
|
|
376
|
-
? {
|
|
376
|
+
? {
|
|
377
|
+
prompt_tokens: numbers.input,
|
|
378
|
+
completion_tokens: numbers.output,
|
|
379
|
+
total_tokens: numbers.total,
|
|
380
|
+
prompt_cache_tokens: providerUsage?.prompt_cache_tokens ?? null,
|
|
381
|
+
}
|
|
377
382
|
: providerUsage,
|
|
378
383
|
estimatedCost: cost,
|
|
379
384
|
currency: billing.price?.currency,
|
|
@@ -47,6 +47,14 @@ export default defineCollection({
|
|
|
47
47
|
defaultValue: 10,
|
|
48
48
|
comment: 'Max request body size in MB. Raise this to accept inline base64 images in vision requests.',
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
name: 'pdfRenderPagesAsImages',
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
defaultValue: false,
|
|
54
|
+
comment:
|
|
55
|
+
'When true, PDF file/file_url blocks are rendered to per-page PNG images and sent as image_url blocks. ' +
|
|
56
|
+
'Requires a registered PdfToImageRenderer. When false or no renderer is available, PDFs are forwarded as file blocks.',
|
|
57
|
+
},
|
|
50
58
|
{
|
|
51
59
|
name: 'quotaEnabled',
|
|
52
60
|
type: 'boolean',
|
|
@@ -1,41 +1,41 @@
|
|
|
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 { defineCollection } from '@nocobase/database';
|
|
11
|
-
|
|
12
|
-
export default defineCollection({
|
|
13
|
-
name: 'aiApiRolePermissions',
|
|
14
|
-
autoGenId: true,
|
|
15
|
-
fields: [
|
|
16
|
-
{
|
|
17
|
-
name: 'roleName',
|
|
18
|
-
type: 'string',
|
|
19
|
-
unique: true,
|
|
20
|
-
comment: 'Role name (links to roles.name)',
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
name: 'enabled',
|
|
24
|
-
type: 'boolean',
|
|
25
|
-
defaultValue: false,
|
|
26
|
-
comment: 'Whether this role can use the AI API at all',
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
name: 'allowAllEmployees',
|
|
30
|
-
type: 'boolean',
|
|
31
|
-
defaultValue: true,
|
|
32
|
-
comment: 'If true, the role may use any AI Employee. If false, only those in allowedEmployees.',
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
name: 'allowedEmployees',
|
|
36
|
-
type: 'json',
|
|
37
|
-
defaultValue: [],
|
|
38
|
-
comment: 'Array of AI Employee usernames this role is allowed to use (when allowAllEmployees=false)',
|
|
39
|
-
},
|
|
40
|
-
],
|
|
41
|
-
});
|
|
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 { defineCollection } from '@nocobase/database';
|
|
11
|
+
|
|
12
|
+
export default defineCollection({
|
|
13
|
+
name: 'aiApiRolePermissions',
|
|
14
|
+
autoGenId: true,
|
|
15
|
+
fields: [
|
|
16
|
+
{
|
|
17
|
+
name: 'roleName',
|
|
18
|
+
type: 'string',
|
|
19
|
+
unique: true,
|
|
20
|
+
comment: 'Role name (links to roles.name)',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'enabled',
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
defaultValue: false,
|
|
26
|
+
comment: 'Whether this role can use the AI API at all',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'allowAllEmployees',
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
defaultValue: true,
|
|
32
|
+
comment: 'If true, the role may use any AI Employee. If false, only those in allowedEmployees.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'allowedEmployees',
|
|
36
|
+
type: 'json',
|
|
37
|
+
defaultValue: [],
|
|
38
|
+
comment: 'Array of AI Employee usernames this role is allowed to use (when allowAllEmployees=false)',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|