plugin-ai-api 1.0.23 → 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 (64) hide show
  1. package/dist/client/757.56952e321dc399b7.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/externalVersion.js +8 -8
  8. package/dist/locale/en-US.json +4 -0
  9. package/dist/locale/vi-VN.json +4 -0
  10. package/dist/locale/zh-CN.json +4 -0
  11. package/dist/server/billing.js +6 -1
  12. package/dist/server/collections/ai-api-config.js +6 -0
  13. package/dist/server/collections/ai-api-usage-records.js +1 -0
  14. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  15. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  16. package/dist/server/plugin.js +10 -0
  17. package/dist/server/resource/ai-api-config.js +5 -0
  18. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  19. package/dist/server/routes/chat-completions.js +110 -19
  20. package/dist/server/routes/completions.js +59 -24
  21. package/dist/server/services/file-processor.js +262 -0
  22. package/dist/server/usage.js +33 -3
  23. package/dist/server/utils/direct-llm-context.js +319 -0
  24. package/dist/server/utils/openai-format.js +21 -2
  25. package/dist/server/validation.js +3 -0
  26. package/dist/swagger.js +42 -3
  27. package/package.json +1 -1
  28. package/src/client-v2/pages/UsagePage.tsx +9 -0
  29. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  30. package/src/locale/en-US.json +4 -0
  31. package/src/locale/vi-VN.json +4 -0
  32. package/src/locale/zh-CN.json +4 -0
  33. package/src/server/__tests__/direct-llm-context.test.ts +206 -0
  34. package/src/server/__tests__/openai-format.test.ts +12 -2
  35. package/src/server/__tests__/request-body.test.ts +45 -2
  36. package/src/server/__tests__/usage-route.test.ts +173 -9
  37. package/src/server/__tests__/usage.test.ts +19 -0
  38. package/src/server/__tests__/validation.test.ts +36 -0
  39. package/src/server/billing.ts +6 -1
  40. package/src/server/collections/ai-api-config.ts +8 -0
  41. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  42. package/src/server/collections/ai-api-usage-records.ts +1 -0
  43. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  44. package/src/server/index.ts +10 -10
  45. package/src/server/middleware/rate-limit.ts +70 -70
  46. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  47. package/src/server/plugin.ts +20 -0
  48. package/src/server/resource/ai-api-config.ts +5 -0
  49. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  50. package/src/server/routes/chat-completions.ts +157 -22
  51. package/src/server/routes/completions.ts +61 -23
  52. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  53. package/src/server/services/file-processor.ts +323 -0
  54. package/src/server/usage.ts +47 -1
  55. package/src/server/utils/direct-llm-context.ts +394 -0
  56. package/src/server/utils/openai-format.ts +25 -2
  57. package/src/server/utils/rate-limiter.ts +83 -83
  58. package/src/server/utils/resolve-service.ts +82 -82
  59. package/src/server/validation.ts +3 -0
  60. package/src/swagger.ts +45 -3
  61. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  62. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  63. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  64. package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
@@ -0,0 +1,206 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ DirectLlmContextError,
5
+ prepareDirectLlmContext,
6
+ type OpenAIMessage,
7
+ parseImageDimensions,
8
+ } from '../utils/direct-llm-context';
9
+
10
+ function context({ behavior = 'reject', metadata = { contextWindow: 120, maxCompletionTokens: 40 } } = {}): Context {
11
+ return {
12
+ state: { currentUser: { id: 1 } },
13
+ db: {
14
+ getRepository: vi.fn((name: string) => {
15
+ if (name === 'aiApiModelMetadata') {
16
+ return {
17
+ findOne: vi.fn().mockResolvedValue({ get: (key: string) => metadata[key as keyof typeof metadata] }),
18
+ };
19
+ }
20
+ if (name === 'aiApiUserQuotaPolicies') {
21
+ return {
22
+ findOne: vi
23
+ .fn()
24
+ .mockResolvedValue({ get: (key: string) => (key === 'contextOverflowBehavior' ? behavior : undefined) }),
25
+ };
26
+ }
27
+ return { findOne: vi.fn() };
28
+ }),
29
+ },
30
+ } as unknown as Context;
31
+ }
32
+
33
+ function request(messages: OpenAIMessage[], tools?: unknown) {
34
+ return {
35
+ serviceName: 'test-service',
36
+ modelId: 'test-model',
37
+ messages,
38
+ tools,
39
+ };
40
+ }
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
+
61
+ describe('direct LLM context preparation', () => {
62
+ it('uses reject when the user has no enabled policy', async () => {
63
+ const ctx = context();
64
+ vi.mocked(ctx.db.getRepository).mockImplementation((name: string) => {
65
+ if (name === 'aiApiModelMetadata') {
66
+ return {
67
+ findOne: vi.fn().mockResolvedValue({ get: (key: string) => (key === 'contextWindow' ? 120 : 40) }),
68
+ } as never;
69
+ }
70
+ return { findOne: vi.fn().mockResolvedValue(null) } as never;
71
+ });
72
+
73
+ await expect(
74
+ prepareDirectLlmContext(ctx, request([{ role: 'user', content: 'x'.repeat(400) }])),
75
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
76
+ });
77
+
78
+ it('keeps a request that fits the model input budget', async () => {
79
+ const messages = [{ role: 'user', content: 'hello' }];
80
+ const prepared = await prepareDirectLlmContext(context(), request(messages));
81
+
82
+ expect(prepared.messages).toBe(messages);
83
+ expect(prepared.truncated).toBe(false);
84
+ expect(prepared.inputTokenBudget).toBe(80);
85
+ });
86
+
87
+ it('rejects a requested output limit above model metadata', async () => {
88
+ await expect(
89
+ prepareDirectLlmContext(context(), { ...request([{ role: 'user', content: 'hello' }]), maxCompletionTokens: 41 }),
90
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'max_completion_tokens_exceeds_model_limit' });
91
+ });
92
+
93
+ it('truncates oldest complete turns while preserving instructions and newest turn', async () => {
94
+ const messages: OpenAIMessage[] = [
95
+ { role: 'system', content: 'Always answer safely.' },
96
+ { role: 'user', content: 'first '.repeat(30) },
97
+ { role: 'assistant', content: 'first answer '.repeat(20) },
98
+ { role: 'user', content: 'latest question' },
99
+ ];
100
+
101
+ const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
102
+
103
+ expect(prepared.truncated).toBe(true);
104
+ expect(prepared.messages).toEqual([messages[0], messages[3]]);
105
+ expect(messages).toHaveLength(4);
106
+ });
107
+
108
+ it('keeps assistant tool calls and their tool responses in the same turn', async () => {
109
+ const messages: OpenAIMessage[] = [
110
+ { role: 'user', content: 'old question '.repeat(30) },
111
+ { role: 'assistant', content: '', tool_calls: [{ id: 'call-old', type: 'function' }] },
112
+ { role: 'tool', tool_call_id: 'call-old', content: 'old result '.repeat(20) },
113
+ { role: 'user', content: 'latest question' },
114
+ ];
115
+
116
+ const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
117
+
118
+ expect(prepared.messages).toEqual([messages[3]]);
119
+ });
120
+
121
+ it('rejects when the newest turn cannot fit without cutting content', async () => {
122
+ await expect(
123
+ prepareDirectLlmContext(context({ behavior: 'truncate' }), request([{ role: 'user', content: 'x'.repeat(400) }])),
124
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
125
+ });
126
+
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.
152
+ await expect(
153
+ prepareDirectLlmContext(
154
+ context(),
155
+ request([{ role: 'user', content: [{ type: 'image_url', image_url: { url: LARGE_PNG_HEADER_BASE64 } }] }]),
156
+ ),
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' });
178
+ });
179
+
180
+ it('counts tool definitions as fixed input overhead', async () => {
181
+ await expect(
182
+ prepareDirectLlmContext(
183
+ context(),
184
+ request(
185
+ [{ role: 'user', content: 'hello' }],
186
+ [{ type: 'function', function: { name: 'large', parameters: { text: 'x'.repeat(400) } } }],
187
+ ),
188
+ ),
189
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
190
+ });
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', () => {
@@ -15,18 +15,20 @@ interface ModelResult {
15
15
  usage_metadata?: Record<string, unknown>;
16
16
  }
17
17
 
18
- function createContext(result: ModelResult): Context {
18
+ function createContext(result: ModelResult) {
19
19
  const model = {
20
20
  invoke: vi.fn().mockResolvedValue(result),
21
21
  modelKwargs: {},
22
22
  };
23
+ let providerCreateCount = 0;
23
24
  class TestProvider {
24
25
  createModel() {
26
+ providerCreateCount += 1;
25
27
  return model;
26
28
  }
27
29
  }
28
30
 
29
- return {
31
+ const ctx = {
30
32
  app: {
31
33
  pm: {
32
34
  get: vi.fn().mockReturnValue({
@@ -37,7 +39,16 @@ function createContext(result: ModelResult): Context {
37
39
  },
38
40
  },
39
41
  db: {
40
- getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(null) }),
42
+ getRepository: vi.fn((name: string) => {
43
+ if (name === 'aiApiModelMetadata') {
44
+ return {
45
+ findOne: vi.fn().mockResolvedValue({
46
+ get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
47
+ }),
48
+ };
49
+ }
50
+ return { findOne: vi.fn().mockResolvedValue(null) };
51
+ }),
41
52
  },
42
53
  log: { error: vi.fn() },
43
54
  request: {
@@ -50,6 +61,8 @@ function createContext(result: ModelResult): Context {
50
61
  state: {},
51
62
  set: vi.fn(),
52
63
  } as unknown as Context;
64
+
65
+ return { ctx, model, getProviderCreateCount: () => providerCreateCount };
53
66
  }
54
67
 
55
68
  class ListenerTarget {
@@ -139,7 +152,16 @@ function createStreamingContext(
139
152
  },
140
153
  },
141
154
  db: {
142
- getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(null) }),
155
+ getRepository: vi.fn((name: string) => {
156
+ if (name === 'aiApiModelMetadata') {
157
+ return {
158
+ findOne: vi.fn().mockResolvedValue({
159
+ get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
160
+ }),
161
+ };
162
+ }
163
+ return { findOne: vi.fn().mockResolvedValue(null) };
164
+ }),
143
165
  },
144
166
  log: { error: vi.fn() },
145
167
  req,
@@ -174,7 +196,7 @@ describe('AI API chat usage collection', () => {
174
196
  });
175
197
 
176
198
  it('keeps the public zero fallback but marks missing provider usage as unavailable internally', async () => {
177
- const ctx = createContext({ content: 'Hello back' });
199
+ const { ctx } = createContext({ content: 'Hello back' });
178
200
 
179
201
  await handleChatCompletions(ctx, {} as PluginAiApiServer);
180
202
 
@@ -183,6 +205,7 @@ describe('AI API chat usage collection', () => {
183
205
  prompt_tokens: 0,
184
206
  completion_tokens: 0,
185
207
  total_tokens: 0,
208
+ prompt_tokens_details: { cached_tokens: null },
186
209
  });
187
210
  expect(ctx.state.aiApiUsageResult).toMatchObject({
188
211
  source: 'unavailable',
@@ -195,7 +218,7 @@ describe('AI API chat usage collection', () => {
195
218
  });
196
219
 
197
220
  it('stores provider usage and provider request ID separately from the gateway response ID', async () => {
198
- const ctx = createContext({
221
+ const { ctx } = createContext({
199
222
  content: 'Hello back',
200
223
  response_metadata: { request_id: 'provider-request-1' },
201
224
  usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
@@ -207,6 +230,7 @@ describe('AI API chat usage collection', () => {
207
230
  prompt_tokens: 8,
208
231
  completion_tokens: 3,
209
232
  total_tokens: 11,
233
+ prompt_tokens_details: { cached_tokens: null },
210
234
  });
211
235
  expect(ctx.state.aiApiUsageResult).toMatchObject({
212
236
  source: 'provider',
@@ -216,6 +240,73 @@ describe('AI API chat usage collection', () => {
216
240
  });
217
241
  });
218
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
+
219
310
  it('emits a usage-only chunk immediately before [DONE] for streaming chat completions', async () => {
220
311
  const { ctx, writes } = createStreamingContext(
221
312
  {
@@ -239,7 +330,12 @@ describe('AI API chat usage collection', () => {
239
330
 
240
331
  expect(finishChunk.choices[0].finish_reason).toBe('stop');
241
332
  expect(usageChunk.choices).toEqual([]);
242
- 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
+ });
243
339
  expect(usageChunk).toHaveProperty('usage.prompt_tokens', 5);
244
340
  expect(ctx.state.aiApiUsageResult).toMatchObject({
245
341
  source: 'provider',
@@ -265,7 +361,12 @@ describe('AI API chat usage collection', () => {
265
361
 
266
362
  expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
267
363
  expect(usageChunk.choices).toEqual([]);
268
- 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
+ });
269
370
  expect(model.stream).toHaveBeenCalledWith(
270
371
  expect.anything(),
271
372
  expect.objectContaining({
@@ -294,7 +395,12 @@ describe('AI API chat usage collection', () => {
294
395
  expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
295
396
  expect(usageChunk.object).toBe('text_completion');
296
397
  expect(usageChunk.choices).toEqual([]);
297
- 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
+ });
298
404
  expect(model.stream).toHaveBeenCalledWith(
299
405
  expect.anything(),
300
406
  expect.objectContaining({
@@ -303,6 +409,31 @@ describe('AI API chat usage collection', () => {
303
409
  );
304
410
  });
305
411
 
412
+ it('rejects context overflow before creating a provider or reserving quota', async () => {
413
+ const { ctx, getProviderCreateCount } = createContext({ content: 'unused' });
414
+ (ctx.request.body as Record<string, unknown>).messages = [{ role: 'user', content: 'x'.repeat(600_000) }];
415
+
416
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
417
+
418
+ expect(ctx.status).toBe(400);
419
+ expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
420
+ expect(ctx.state.aiApiLlmBilling).toBeUndefined();
421
+ expect(getProviderCreateCount()).toBe(0);
422
+ });
423
+
424
+ it('rejects oversized legacy prompts before creating a provider or reserving quota', async () => {
425
+ const { ctx, model, getProviderCreateCount } = createContext({ content: 'unused' });
426
+ (ctx.request.body as Record<string, unknown>).prompt = 'x'.repeat(600_000);
427
+
428
+ await handleCompletions(ctx, {} as PluginAiApiServer);
429
+
430
+ expect(ctx.status).toBe(400);
431
+ expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
432
+ expect(ctx.state.aiApiLlmBilling).toBeUndefined();
433
+ expect(getProviderCreateCount()).toBe(0);
434
+ expect(model.invoke).not.toHaveBeenCalled();
435
+ });
436
+
306
437
  it('does not emit a usage-only chunk when the provider omits usage metadata', async () => {
307
438
  const { ctx, writes } = createStreamingContext({ content: 'Silent' }, { include_usage: true });
308
439
 
@@ -319,4 +450,37 @@ describe('AI API chat usage collection', () => {
319
450
  expect(precedingChunk.usage).toBeNull();
320
451
  expect(ctx.state.aiApiUsageResult).toMatchObject({ source: 'unavailable' });
321
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
+ });
322
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
 
@@ -0,0 +1,36 @@
1
+ import type { Model } from '@nocobase/database';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('dayjs', () => ({
5
+ default: () => ({ tz: vi.fn() }),
6
+ }));
7
+
8
+ import { validateQuotaPolicy } from '../validation';
9
+
10
+ function quotaPolicy(values: Record<string, unknown>): Model {
11
+ return {
12
+ get: (key: string) => values[key],
13
+ } as Model;
14
+ }
15
+
16
+ const requiredPolicy = {
17
+ periodType: 'monthly',
18
+ missingUsageBehavior: 'use_reserved',
19
+ timezone: 'UTC',
20
+ };
21
+
22
+ describe('AI API quota policy validation', () => {
23
+ it('uses reject when context overflow behavior is absent', () => {
24
+ expect(() => validateQuotaPolicy(quotaPolicy(requiredPolicy))).not.toThrow();
25
+ });
26
+
27
+ it.each(['reject', 'truncate'] as const)('accepts %s context overflow behavior', (contextOverflowBehavior) => {
28
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior }))).not.toThrow();
29
+ });
30
+
31
+ it('rejects an unsupported context overflow behavior', () => {
32
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior: 'compact' }))).toThrow(
33
+ 'contextOverflowBehavior must be reject or truncate.',
34
+ );
35
+ });
36
+ });
@@ -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',