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.
- package/dist/client/757.56952e321dc399b7.js +10 -0
- package/dist/client/902.e74518750f1e4201.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
- package/dist/client-v2/902.c7c00a565085438a.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +4 -0
- package/dist/locale/vi-VN.json +4 -0
- package/dist/locale/zh-CN.json +4 -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/collections/ai-api-user-quota-policies.js +2 -1
- 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 +110 -19
- package/dist/server/routes/completions.js +59 -24
- package/dist/server/services/file-processor.js +262 -0
- package/dist/server/usage.js +33 -3
- package/dist/server/utils/direct-llm-context.js +319 -0
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/server/validation.js +3 -0
- package/dist/swagger.js +42 -3
- package/package.json +1 -1
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
- package/src/locale/en-US.json +4 -0
- package/src/locale/vi-VN.json +4 -0
- package/src/locale/zh-CN.json +4 -0
- package/src/server/__tests__/direct-llm-context.test.ts +206 -0
- 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 +173 -9
- package/src/server/__tests__/usage.test.ts +19 -0
- package/src/server/__tests__/validation.test.ts +36 -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/collections/ai-api-user-quota-policies.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 +157 -22
- package/src/server/routes/completions.ts +61 -23
- 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 +394 -0
- 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/server/validation.ts +3 -0
- package/src/swagger.ts +45 -3
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
- 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({
|
|
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', () => {
|
|
@@ -15,18 +15,20 @@ interface ModelResult {
|
|
|
15
15
|
usage_metadata?: Record<string, unknown>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function createContext(result: ModelResult)
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
+
});
|
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',
|