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.
Files changed (54) hide show
  1. package/dist/client/757.56952e321dc399b7.js +10 -0
  2. package/dist/client/index.js +1 -1
  3. package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
  4. package/dist/client-v2/index.js +1 -1
  5. package/dist/externalVersion.js +8 -8
  6. package/dist/locale/en-US.json +1 -0
  7. package/dist/locale/vi-VN.json +1 -0
  8. package/dist/locale/zh-CN.json +1 -0
  9. package/dist/server/billing.js +6 -1
  10. package/dist/server/collections/ai-api-config.js +6 -0
  11. package/dist/server/collections/ai-api-usage-records.js +1 -0
  12. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  13. package/dist/server/plugin.js +10 -0
  14. package/dist/server/resource/ai-api-config.js +5 -0
  15. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  16. package/dist/server/routes/chat-completions.js +89 -10
  17. package/dist/server/routes/completions.js +32 -10
  18. package/dist/server/services/file-processor.js +262 -0
  19. package/dist/server/usage.js +33 -3
  20. package/dist/server/utils/direct-llm-context.js +150 -15
  21. package/dist/server/utils/openai-format.js +21 -2
  22. package/dist/swagger.js +42 -3
  23. package/package.json +1 -1
  24. package/src/client-v2/pages/UsagePage.tsx +9 -0
  25. package/src/locale/en-US.json +1 -0
  26. package/src/locale/vi-VN.json +1 -0
  27. package/src/locale/zh-CN.json +1 -0
  28. package/src/server/__tests__/direct-llm-context.test.ts +87 -6
  29. package/src/server/__tests__/openai-format.test.ts +12 -2
  30. package/src/server/__tests__/request-body.test.ts +45 -2
  31. package/src/server/__tests__/usage-route.test.ts +120 -3
  32. package/src/server/__tests__/usage.test.ts +19 -0
  33. package/src/server/billing.ts +6 -1
  34. package/src/server/collections/ai-api-config.ts +8 -0
  35. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  36. package/src/server/collections/ai-api-usage-records.ts +1 -0
  37. package/src/server/index.ts +10 -10
  38. package/src/server/middleware/rate-limit.ts +70 -70
  39. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  40. package/src/server/plugin.ts +20 -0
  41. package/src/server/resource/ai-api-config.ts +5 -0
  42. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  43. package/src/server/routes/chat-completions.ts +134 -11
  44. package/src/server/routes/completions.ts +33 -7
  45. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  46. package/src/server/services/file-processor.ts +323 -0
  47. package/src/server/usage.ts +47 -1
  48. package/src/server/utils/direct-llm-context.ts +198 -20
  49. package/src/server/utils/openai-format.ts +25 -2
  50. package/src/server/utils/rate-limiter.ts +83 -83
  51. package/src/server/utils/resolve-service.ts +82 -82
  52. package/src/swagger.ts +45 -3
  53. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  54. 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. Only text and image_url blocks are forwarded to the provider; any other type is rejected with 400 unsupported_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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {
@@ -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} />
@@ -63,6 +63,7 @@
63
63
  "Input tokens": "Input tokens",
64
64
  "Output tokens": "Output tokens",
65
65
  "Total tokens": "Total tokens",
66
+ "Prompt cache tokens": "Prompt cache tokens",
66
67
  "Cost": "Cost",
67
68
  "Cost status": "Cost status",
68
69
  "Request ID": "Request ID",
@@ -63,6 +63,7 @@
63
63
  "Input tokens": "Input token",
64
64
  "Output tokens": "Output token",
65
65
  "Total tokens": "Tổng token",
66
+ "Prompt cache tokens": "Prompt cache token",
66
67
  "Cost": "Chi phí",
67
68
  "Cost status": "Trạng thái chi phí",
68
69
  "Request ID": "Request ID",
@@ -63,6 +63,7 @@
63
63
  "Input tokens": "输入令牌",
64
64
  "Output tokens": "输出令牌",
65
65
  "Total tokens": "总令牌",
66
+ "Prompt cache tokens": "提示缓存令牌",
66
67
  "Cost": "费用",
67
68
  "Cost status": "费用状态",
68
69
  "Request ID": "请求 ID",
@@ -1,6 +1,11 @@
1
1
  import type { Context } from '@nocobase/actions';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
- import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
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('rejects image content until a model-specific estimator is available', async () => {
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: 'context_estimation_unsupported' });
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({ prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 });
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({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
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: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } }] },
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("'file' is not supported");
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({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
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({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
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({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
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
 
@@ -373,7 +373,12 @@ export async function finalizeLlmBilling(
373
373
 
374
374
  return {
375
375
  usage: numbers
376
- ? { prompt_tokens: numbers.input, completion_tokens: numbers.output, total_tokens: numbers.total }
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
+ });