plugin-ai-api 1.1.0 → 1.1.2
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/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/97.1bc5103fd9d995a8.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/97.96da323832251796.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +38 -1
- package/dist/locale/vi-VN.json +177 -138
- package/dist/locale/zh-CN.json +177 -138
- package/dist/server/billing.js +17 -9
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-model-prices.js +8 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/auth.js +16 -0
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +34 -5
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +26 -4
- package/dist/server/utils/direct-llm-context.js +10 -9
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +45 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/index.tsx +10 -10
- package/src/client/models/index.ts +12 -12
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +38 -1
- package/src/locale/vi-VN.json +177 -138
- package/src/locale/zh-CN.json +177 -138
- package/src/server/__tests__/billing-quota.test.ts +8 -2
- package/src/server/__tests__/billing.test.ts +13 -0
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +15 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/billing.ts +29 -6
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-model-prices.ts +8 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/auth.ts +21 -1
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +42 -6
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +30 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +23 -12
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +50 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
|
@@ -0,0 +1,589 @@
|
|
|
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 type { Context } from '@nocobase/actions';
|
|
11
|
+
import type { Model } from '@nocobase/database';
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import type PluginAiApiServer from '../plugin';
|
|
14
|
+
import { handleChatCompletions } from '../routes/chat-completions';
|
|
15
|
+
import { handleEmbeddings } from '../routes/embeddings';
|
|
16
|
+
import { resolveModelReference, resolveModelString } from '../utils/resolve-service';
|
|
17
|
+
import { detectRequestSignals, listAccessibleVirtualModels } from '../utils/virtual-models';
|
|
18
|
+
import { invalidateGroupAccessCache } from '../utils/user-permissions';
|
|
19
|
+
|
|
20
|
+
vi.mock('../utils/resolve-service', () => ({
|
|
21
|
+
resolveModelString: vi.fn(),
|
|
22
|
+
resolveModelReference: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
interface ModelResult {
|
|
26
|
+
content: string;
|
|
27
|
+
response_metadata?: Record<string, unknown>;
|
|
28
|
+
usage_metadata?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RepositoryOverride {
|
|
32
|
+
findOne?: () => unknown;
|
|
33
|
+
find?: () => unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createContext(
|
|
37
|
+
result: ModelResult,
|
|
38
|
+
requestBody: Record<string, unknown>,
|
|
39
|
+
repositories: Record<string, RepositoryOverride>,
|
|
40
|
+
metadataGet?: (key: string) => unknown,
|
|
41
|
+
) {
|
|
42
|
+
const model = {
|
|
43
|
+
invoke: vi.fn().mockResolvedValue(result),
|
|
44
|
+
modelKwargs: {},
|
|
45
|
+
bindTools: vi.fn(function (this: unknown) {
|
|
46
|
+
return this;
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
class TestProvider {
|
|
50
|
+
createModel() {
|
|
51
|
+
return model;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const getMetadataValue =
|
|
56
|
+
metadataGet ??
|
|
57
|
+
((key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true));
|
|
58
|
+
|
|
59
|
+
const ctx = {
|
|
60
|
+
app: {
|
|
61
|
+
pm: {
|
|
62
|
+
get: vi.fn().mockReturnValue({
|
|
63
|
+
aiManager: {
|
|
64
|
+
llmProviders: new Map([['test-provider', { provider: TestProvider }]]),
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
db: {
|
|
70
|
+
getRepository: vi.fn((name: string) => {
|
|
71
|
+
if (repositories[name]) return repositories[name];
|
|
72
|
+
if (name === 'aiApiModelMetadata') {
|
|
73
|
+
return {
|
|
74
|
+
findOne: vi.fn().mockResolvedValue({ get: getMetadataValue }),
|
|
75
|
+
find: vi.fn().mockResolvedValue([]),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (name === 'aiApiUsageGroups') {
|
|
79
|
+
return {
|
|
80
|
+
findOne: vi.fn().mockResolvedValue({ id: 1, name: 'Default', isDefault: true, allowAllModels: true }),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { findOne: vi.fn().mockResolvedValue(null), find: vi.fn().mockResolvedValue([]) };
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
log: { error: vi.fn() },
|
|
87
|
+
request: { body: requestBody },
|
|
88
|
+
state: { currentUser: { id: 1 } } as Record<string, unknown>,
|
|
89
|
+
set: vi.fn(),
|
|
90
|
+
// Minimal req/res mocks so streaming.ts helpers do not touch undefined.
|
|
91
|
+
req: {
|
|
92
|
+
aborted: false,
|
|
93
|
+
once: vi.fn(),
|
|
94
|
+
off: vi.fn(),
|
|
95
|
+
},
|
|
96
|
+
res: {
|
|
97
|
+
writableEnded: false,
|
|
98
|
+
destroyed: false,
|
|
99
|
+
once: vi.fn(),
|
|
100
|
+
off: vi.fn(),
|
|
101
|
+
write: vi.fn(() => true),
|
|
102
|
+
end: vi.fn(),
|
|
103
|
+
},
|
|
104
|
+
} as unknown as Context;
|
|
105
|
+
|
|
106
|
+
return { ctx, model };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const VIRTUAL_MODEL_ROW = {
|
|
110
|
+
get: (key: string) => {
|
|
111
|
+
const row: Record<string, unknown> = {
|
|
112
|
+
name: 'auto',
|
|
113
|
+
mode: 'chat',
|
|
114
|
+
enabled: true,
|
|
115
|
+
fallbackModel: 'fallback-svc/fallback-model',
|
|
116
|
+
visionModels: ['vision-svc/vision-model'],
|
|
117
|
+
toolModels: ['tool-svc/tool-model'],
|
|
118
|
+
reasoningModels: ['reasoning-svc/reasoning-model'],
|
|
119
|
+
cheapModels: [],
|
|
120
|
+
generalModels: ['general-svc/general-model'],
|
|
121
|
+
};
|
|
122
|
+
return row[key];
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
describe('AI API virtual model routing (llm mode)', () => {
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
vi.clearAllMocks();
|
|
129
|
+
invalidateGroupAccessCache();
|
|
130
|
+
// Default: every concrete model resolves successfully.
|
|
131
|
+
const resolveAny = async (_ctx: unknown, modelString: unknown) => {
|
|
132
|
+
const [service, modelId] = String(modelString).split('/');
|
|
133
|
+
if (!service || !modelId) return null;
|
|
134
|
+
return {
|
|
135
|
+
service: { enabled: true, name: service, options: {}, provider: 'test-provider' } as unknown as Model,
|
|
136
|
+
modelId,
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
vi.mocked(resolveModelReference).mockImplementation(resolveAny);
|
|
140
|
+
vi.mocked(resolveModelString).mockImplementation(resolveAny);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('skips bucket candidates whose service reference does not resolve (no default-service fallback)', async () => {
|
|
144
|
+
// A typo like "missing-svc/model" must be skipped, not silently reinterpreted as a model id
|
|
145
|
+
// on the default service. Only the valid fallback resolves.
|
|
146
|
+
const fallbackModel = {
|
|
147
|
+
enabled: true,
|
|
148
|
+
name: 'fallback-svc',
|
|
149
|
+
title: 'fallback-svc',
|
|
150
|
+
options: {},
|
|
151
|
+
provider: 'test-provider',
|
|
152
|
+
} as unknown as Model;
|
|
153
|
+
vi.mocked(resolveModelReference).mockImplementation(async (_ctx, reference) => {
|
|
154
|
+
const [service, modelId] = String(reference).split('/');
|
|
155
|
+
if (service === 'missing-svc') return null;
|
|
156
|
+
if (!service || !modelId) return null;
|
|
157
|
+
if (service === 'fallback-svc') return { service: fallbackModel, modelId };
|
|
158
|
+
return {
|
|
159
|
+
service: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
name: service,
|
|
162
|
+
title: service,
|
|
163
|
+
options: {},
|
|
164
|
+
provider: 'test-provider',
|
|
165
|
+
} as unknown as Model,
|
|
166
|
+
modelId,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const row = {
|
|
170
|
+
get: (key: string) =>
|
|
171
|
+
(
|
|
172
|
+
({
|
|
173
|
+
name: 'auto',
|
|
174
|
+
mode: 'chat',
|
|
175
|
+
enabled: true,
|
|
176
|
+
fallbackModel: 'fallback-svc/fallback-model',
|
|
177
|
+
visionModels: ['missing-svc/typo-model'],
|
|
178
|
+
toolModels: [],
|
|
179
|
+
reasoningModels: [],
|
|
180
|
+
cheapModels: [],
|
|
181
|
+
generalModels: [],
|
|
182
|
+
}) as Record<string, unknown>
|
|
183
|
+
)[key],
|
|
184
|
+
};
|
|
185
|
+
const { ctx } = createContext(
|
|
186
|
+
{ content: 'fallback used' },
|
|
187
|
+
{
|
|
188
|
+
model: 'auto',
|
|
189
|
+
messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/x.png' } }] }],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
aiApiVirtualModels: { findOne: () => Promise.resolve(row), find: () => Promise.resolve([row]) },
|
|
193
|
+
aiApiUsageGroups: {
|
|
194
|
+
findOne: () =>
|
|
195
|
+
Promise.resolve({
|
|
196
|
+
id: 1,
|
|
197
|
+
name: 'Default',
|
|
198
|
+
isDefault: true,
|
|
199
|
+
allowedLlmServices: ['fallback-svc'],
|
|
200
|
+
allowAllModels: true,
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer).catch((e) => {
|
|
207
|
+
throw new Error(`handleChatCompletions threw: ${e}\nctx.body=${JSON.stringify(ctx.body)}`);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (ctx.status !== 200) {
|
|
211
|
+
throw new Error(`expected 200, got ${ctx.status}: ${JSON.stringify(ctx.body)}`);
|
|
212
|
+
}
|
|
213
|
+
expect(ctx.state.aiApiRoutingReason).toBe('fallback');
|
|
214
|
+
// The typo candidate was attempted strictly and returned null — not resolved on another service.
|
|
215
|
+
expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'missing-svc/typo-model');
|
|
216
|
+
// Routing fell through to the valid fallback.
|
|
217
|
+
expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'fallback-svc/fallback-model');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('skips bucket candidates with typo service references (direct resolution)', async () => {
|
|
221
|
+
// Pure resolution check without the HTTP handler, to isolate routing from request plumbing.
|
|
222
|
+
const { resolveVirtualModel } = await import('../utils/virtual-models');
|
|
223
|
+
const fallbackModel = {
|
|
224
|
+
enabled: true,
|
|
225
|
+
name: 'fallback-svc',
|
|
226
|
+
title: 'fallback-svc',
|
|
227
|
+
options: {},
|
|
228
|
+
provider: 'test-provider',
|
|
229
|
+
} as unknown as Model;
|
|
230
|
+
vi.mocked(resolveModelReference).mockImplementation(async (_ctx, reference) => {
|
|
231
|
+
const [service, modelId] = String(reference).split('/');
|
|
232
|
+
if (service === 'missing-svc') return null;
|
|
233
|
+
if (!service || !modelId) return null;
|
|
234
|
+
if (service === 'fallback-svc') return { service: fallbackModel, modelId };
|
|
235
|
+
return {
|
|
236
|
+
service: {
|
|
237
|
+
enabled: true,
|
|
238
|
+
name: service,
|
|
239
|
+
title: service,
|
|
240
|
+
options: {},
|
|
241
|
+
provider: 'test-provider',
|
|
242
|
+
} as unknown as Model,
|
|
243
|
+
modelId,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
const row = {
|
|
247
|
+
get: (key: string) =>
|
|
248
|
+
(
|
|
249
|
+
({
|
|
250
|
+
name: 'auto',
|
|
251
|
+
mode: 'chat',
|
|
252
|
+
enabled: true,
|
|
253
|
+
fallbackModel: 'fallback-svc/fallback-model',
|
|
254
|
+
visionModels: ['missing-svc/typo-model'],
|
|
255
|
+
toolModels: [],
|
|
256
|
+
reasoningModels: [],
|
|
257
|
+
cheapModels: [],
|
|
258
|
+
generalModels: [],
|
|
259
|
+
}) as Record<string, unknown>
|
|
260
|
+
)[key],
|
|
261
|
+
};
|
|
262
|
+
const { ctx } = createContext(
|
|
263
|
+
{ content: 'fallback used' },
|
|
264
|
+
{
|
|
265
|
+
model: 'auto',
|
|
266
|
+
messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/x.png' } }] }],
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
aiApiVirtualModels: { findOne: () => Promise.resolve(row), find: () => Promise.resolve([row]) },
|
|
270
|
+
aiApiUsageGroups: {
|
|
271
|
+
findOne: () =>
|
|
272
|
+
Promise.resolve({
|
|
273
|
+
id: 1,
|
|
274
|
+
name: 'Default',
|
|
275
|
+
isDefault: true,
|
|
276
|
+
allowedLlmServices: ['fallback-svc'],
|
|
277
|
+
allowAllModels: true,
|
|
278
|
+
}),
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const result = await resolveVirtualModel(ctx, 'auto', ctx.request.body as Record<string, unknown>, 'chat');
|
|
284
|
+
|
|
285
|
+
expect(result?.status).toBe('resolved');
|
|
286
|
+
expect(result?.reason).toBe('fallback');
|
|
287
|
+
expect((result as { resolved?: { modelId: string } }).resolved?.modelId).toBe('fallback-model');
|
|
288
|
+
expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'missing-svc/typo-model');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('derives the general bucket from every metadata tier ordered by sortOrder when no bucket is configured', async () => {
|
|
292
|
+
// Characterization: empty generalModels/cheapModels means the general bucket is derived
|
|
293
|
+
// from model metadata across ALL reasoningTiers, ordered by sortOrder. A plain request
|
|
294
|
+
// (no capability signals) is served by the lowest-sortOrder row even if that row is a
|
|
295
|
+
// reasoning-tier model — the general bucket is a catch-all, not a tier filter.
|
|
296
|
+
const { resolveVirtualModel } = await import('../utils/virtual-models');
|
|
297
|
+
const metadataRow = (llmService: string, model: string, reasoningTier: string, sortOrder: number) => ({
|
|
298
|
+
get: (key: string) => ({ llmService, model, reasoningTier, sortOrder })[key],
|
|
299
|
+
});
|
|
300
|
+
const row = {
|
|
301
|
+
get: (key: string) =>
|
|
302
|
+
(
|
|
303
|
+
({
|
|
304
|
+
name: 'auto',
|
|
305
|
+
mode: 'chat',
|
|
306
|
+
enabled: true,
|
|
307
|
+
fallbackModel: 'svc/fallback',
|
|
308
|
+
visionModels: [],
|
|
309
|
+
toolModels: [],
|
|
310
|
+
reasoningModels: [],
|
|
311
|
+
cheapModels: [],
|
|
312
|
+
generalModels: [],
|
|
313
|
+
}) as Record<string, unknown>
|
|
314
|
+
)[key],
|
|
315
|
+
};
|
|
316
|
+
const { ctx } = createContext(
|
|
317
|
+
{ content: 'general answer' },
|
|
318
|
+
{ model: 'auto', messages: [{ role: 'user', content: 'Hello' }], stream: false },
|
|
319
|
+
{
|
|
320
|
+
aiApiVirtualModels: { findOne: () => Promise.resolve(row), find: () => Promise.resolve([row]) },
|
|
321
|
+
aiApiModelMetadata: {
|
|
322
|
+
findOne: () => Promise.resolve(null),
|
|
323
|
+
find: () =>
|
|
324
|
+
Promise.resolve([
|
|
325
|
+
metadataRow('svc', 'reasoning-a', 'reasoning', 1),
|
|
326
|
+
metadataRow('svc', 'general-b', 'general', 2),
|
|
327
|
+
]),
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const result = await resolveVirtualModel(ctx, 'auto', ctx.request.body as Record<string, unknown>, 'chat');
|
|
333
|
+
|
|
334
|
+
expect(result?.status).toBe('resolved');
|
|
335
|
+
expect(result?.reason).toBe('general');
|
|
336
|
+
expect((result as { resolved?: { modelId: string } }).resolved?.modelId).toBe('reasoning-a');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('routes a vision request to the first permitted vision candidate', async () => {
|
|
340
|
+
const { ctx } = createContext(
|
|
341
|
+
{ content: 'I see an image.' },
|
|
342
|
+
{
|
|
343
|
+
model: 'auto',
|
|
344
|
+
messages: [
|
|
345
|
+
{
|
|
346
|
+
role: 'user',
|
|
347
|
+
content: [
|
|
348
|
+
{ type: 'text', text: 'Describe this' },
|
|
349
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,aGVsbG8=' } },
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
stream: false,
|
|
354
|
+
},
|
|
355
|
+
{ aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
359
|
+
|
|
360
|
+
if (ctx.status !== 200) throw new Error(`vision test expected 200, got ${ctx.status}: ${JSON.stringify(ctx.body)}`);
|
|
361
|
+
expect(ctx.state.aiApiVirtualModel).toBe('auto');
|
|
362
|
+
expect(ctx.state.aiApiRoutingReason).toBe('vision');
|
|
363
|
+
expect(ctx.state.aiApiLlmBilling).toMatchObject({
|
|
364
|
+
resolution: { service: 'vision-svc', model: 'vision-model' },
|
|
365
|
+
});
|
|
366
|
+
// Response must carry the resolved concrete model, not the alias.
|
|
367
|
+
expect((ctx.body as { model: string }).model).toBe('vision-svc/vision-model');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('routes a tool-calling request to the tool bucket', async () => {
|
|
371
|
+
const { ctx } = createContext(
|
|
372
|
+
{ content: 'ok' },
|
|
373
|
+
{
|
|
374
|
+
model: 'auto',
|
|
375
|
+
messages: [{ role: 'user', content: 'Call a tool' }],
|
|
376
|
+
tools: [{ type: 'function', function: { name: 'x' } }],
|
|
377
|
+
stream: false,
|
|
378
|
+
},
|
|
379
|
+
{ aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
383
|
+
|
|
384
|
+
expect(ctx.status).toBe(200);
|
|
385
|
+
expect(ctx.state.aiApiRoutingReason).toBe('tools');
|
|
386
|
+
expect(ctx.state.aiApiLlmBilling).toMatchObject({
|
|
387
|
+
resolution: { service: 'tool-svc', model: 'tool-model' },
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('treats file blocks as vision input', () => {
|
|
392
|
+
expect(
|
|
393
|
+
detectRequestSignals({
|
|
394
|
+
messages: [
|
|
395
|
+
{
|
|
396
|
+
role: 'user',
|
|
397
|
+
content: [{ type: 'file', file: { file_data: 'data:application/pdf;base64,YQ==' } }],
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
}),
|
|
401
|
+
).toMatchObject({ hasImage: true });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('routes an explicit reasoning request to the reasoning bucket', async () => {
|
|
405
|
+
const { ctx } = createContext(
|
|
406
|
+
{ content: 'reasoned answer' },
|
|
407
|
+
{
|
|
408
|
+
model: 'auto',
|
|
409
|
+
messages: [{ role: 'user', content: 'Think carefully' }],
|
|
410
|
+
reasoning_effort: 'high',
|
|
411
|
+
stream: false,
|
|
412
|
+
},
|
|
413
|
+
{ aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
417
|
+
|
|
418
|
+
expect(ctx.status).toBe(200);
|
|
419
|
+
expect(ctx.state.aiApiRoutingReason).toBe('reasoning');
|
|
420
|
+
expect(ctx.state.aiApiLlmBilling).toMatchObject({
|
|
421
|
+
resolution: { service: 'reasoning-svc', model: 'reasoning-model' },
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('does not resolve a chat alias for the embeddings endpoint', async () => {
|
|
426
|
+
const { ctx } = createContext(
|
|
427
|
+
{ content: '' },
|
|
428
|
+
{ model: 'auto', input: 'Embed this' },
|
|
429
|
+
{ aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
await handleEmbeddings(ctx, {} as PluginAiApiServer);
|
|
433
|
+
|
|
434
|
+
expect(ctx.status).toBe(404);
|
|
435
|
+
expect(ctx.body).toMatchObject({ error: { code: 'model_not_found' } });
|
|
436
|
+
expect(resolveModelReference).not.toHaveBeenCalled();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('does not resolve an embedding alias for the chat endpoint', async () => {
|
|
440
|
+
const embeddingRow = {
|
|
441
|
+
get: (key: string) => (key === 'mode' ? 'embedding' : VIRTUAL_MODEL_ROW.get(key)),
|
|
442
|
+
};
|
|
443
|
+
const { ctx } = createContext(
|
|
444
|
+
{ content: '' },
|
|
445
|
+
{ model: 'auto', messages: [{ role: 'user', content: 'Hello' }] },
|
|
446
|
+
{ aiApiVirtualModels: { findOne: () => Promise.resolve(embeddingRow) } },
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
450
|
+
|
|
451
|
+
expect(ctx.status).toBe(404);
|
|
452
|
+
expect(ctx.body).toMatchObject({ error: { code: 'model_not_found' } });
|
|
453
|
+
expect(resolveModelReference).not.toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('resolves an embedding alias directly to its fallback without using chat buckets', async () => {
|
|
457
|
+
const embeddingRow = {
|
|
458
|
+
get: (key: string) => (key === 'mode' ? 'embedding' : VIRTUAL_MODEL_ROW.get(key)),
|
|
459
|
+
};
|
|
460
|
+
const embeddingModel = { embedDocuments: vi.fn().mockResolvedValue([[0.1, 0.2]]) };
|
|
461
|
+
class EmbeddingProvider {
|
|
462
|
+
createEmbedding() {
|
|
463
|
+
return embeddingModel;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const { ctx } = createContext(
|
|
467
|
+
{ content: '' },
|
|
468
|
+
{ model: 'auto', input: 'Embed this' },
|
|
469
|
+
{ aiApiVirtualModels: { findOne: () => Promise.resolve(embeddingRow) } },
|
|
470
|
+
);
|
|
471
|
+
vi.mocked(ctx.app.pm.get).mockReturnValue({
|
|
472
|
+
aiManager: { llmProviders: new Map([['test-provider', { embedding: EmbeddingProvider }]]) },
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
await handleEmbeddings(ctx, {} as PluginAiApiServer);
|
|
476
|
+
|
|
477
|
+
expect(ctx.status).toBe(200);
|
|
478
|
+
expect(ctx.state.aiApiRoutingReason).toBe('fallback');
|
|
479
|
+
expect(ctx.body).toMatchObject({ model: 'fallback-svc/fallback-model' });
|
|
480
|
+
expect(resolveModelReference).toHaveBeenCalledTimes(1);
|
|
481
|
+
expect(resolveModelReference).toHaveBeenCalledWith(ctx, 'fallback-svc/fallback-model');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('falls back to the configured fallback when no bucket candidate is permitted', async () => {
|
|
485
|
+
// Only the fallback model resolves; every other candidate returns null.
|
|
486
|
+
vi.mocked(resolveModelReference).mockImplementation(async (_ctx, modelString) => {
|
|
487
|
+
if (String(modelString) === 'fallback-svc/fallback-model') {
|
|
488
|
+
return {
|
|
489
|
+
service: { enabled: true, name: 'fallback-svc', options: {}, provider: 'test-provider' } as unknown as Model,
|
|
490
|
+
modelId: 'fallback-model',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const { ctx } = createContext(
|
|
497
|
+
{ content: 'fallback ok' },
|
|
498
|
+
{
|
|
499
|
+
model: 'auto',
|
|
500
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
501
|
+
stream: false,
|
|
502
|
+
},
|
|
503
|
+
{ aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) } },
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
507
|
+
|
|
508
|
+
expect(ctx.status).toBe(200);
|
|
509
|
+
expect(ctx.state.aiApiRoutingReason).toBe('fallback');
|
|
510
|
+
expect(ctx.state.aiApiLlmBilling).toMatchObject({
|
|
511
|
+
resolution: { service: 'fallback-svc', model: 'fallback-model' },
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('does not bypass the caller scope through the fallback model', async () => {
|
|
516
|
+
const restrictedGroup = {
|
|
517
|
+
id: 2,
|
|
518
|
+
name: 'Restricted',
|
|
519
|
+
isDefault: true,
|
|
520
|
+
allowedLlmServices: ['other-svc'],
|
|
521
|
+
allowAllModels: true,
|
|
522
|
+
};
|
|
523
|
+
const { ctx } = createContext(
|
|
524
|
+
{ content: '' },
|
|
525
|
+
{ model: 'auto', messages: [{ role: 'user', content: 'Hello' }] },
|
|
526
|
+
{
|
|
527
|
+
aiApiVirtualModels: { findOne: () => Promise.resolve(VIRTUAL_MODEL_ROW) },
|
|
528
|
+
aiApiUsageGroups: { findOne: () => Promise.resolve(restrictedGroup) },
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
533
|
+
|
|
534
|
+
expect(ctx.status).toBe(403);
|
|
535
|
+
expect(ctx.body).toMatchObject({ error: { code: 'model_not_available' } });
|
|
536
|
+
expect(ctx.state.aiApiLlmBilling).toBeUndefined();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('passes through non-virtual models unchanged', async () => {
|
|
540
|
+
const { ctx } = createContext(
|
|
541
|
+
{ content: 'direct' },
|
|
542
|
+
{
|
|
543
|
+
model: 'direct-svc/direct-model',
|
|
544
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
545
|
+
stream: false,
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
aiApiVirtualModels: { findOne: () => Promise.resolve(null) },
|
|
549
|
+
aiApiUsageGroups: {
|
|
550
|
+
findOne: () =>
|
|
551
|
+
Promise.resolve({
|
|
552
|
+
id: 1,
|
|
553
|
+
name: 'Default',
|
|
554
|
+
isDefault: true,
|
|
555
|
+
allowedLlmServices: ['direct-svc'],
|
|
556
|
+
allowAllModels: true,
|
|
557
|
+
}),
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
563
|
+
|
|
564
|
+
if (ctx.status !== 200)
|
|
565
|
+
throw new Error(`non-virtual test expected 200, got ${ctx.status}: ${JSON.stringify(ctx.body)}`);
|
|
566
|
+
expect(ctx.state.aiApiVirtualModel).toBeUndefined();
|
|
567
|
+
expect(ctx.state.aiApiRoutingReason).toBeUndefined();
|
|
568
|
+
expect(ctx.state.aiApiLlmBilling).toMatchObject({
|
|
569
|
+
resolution: { service: 'direct-svc', model: 'direct-model' },
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('does not expose an alias whose fallback model is outside the caller scope', async () => {
|
|
574
|
+
const { ctx } = createContext(
|
|
575
|
+
{ content: '' },
|
|
576
|
+
{},
|
|
577
|
+
{ aiApiVirtualModels: { find: () => Promise.resolve([VIRTUAL_MODEL_ROW]) } },
|
|
578
|
+
);
|
|
579
|
+
const scope = {
|
|
580
|
+
groupId: 1,
|
|
581
|
+
allowedServices: ['other-svc'],
|
|
582
|
+
allowAllModels: true,
|
|
583
|
+
allowedModels: new Set<string>(),
|
|
584
|
+
lookupFailed: false,
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
await expect(listAccessibleVirtualModels(ctx, scope, [])).resolves.toEqual([]);
|
|
588
|
+
});
|
|
589
|
+
});
|
package/src/server/billing.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface PriceSnapshot {
|
|
|
24
24
|
id: string | number | bigint;
|
|
25
25
|
currency: string;
|
|
26
26
|
inputPricePerMillionTokens: string;
|
|
27
|
+
cacheInputPricePerMillionTokens: string;
|
|
27
28
|
outputPricePerMillionTokens: string;
|
|
28
29
|
fixedCostPerRequest: string;
|
|
29
30
|
}
|
|
@@ -60,6 +61,7 @@ export interface BillingFinalization {
|
|
|
60
61
|
groupId?: string | number | bigint;
|
|
61
62
|
quotaMode?: QuotaMode;
|
|
62
63
|
inputPricePerMillionTokens?: string;
|
|
64
|
+
cacheInputPricePerMillionTokens?: string;
|
|
63
65
|
outputPricePerMillionTokens?: string;
|
|
64
66
|
fixedCostPerRequest?: string;
|
|
65
67
|
}
|
|
@@ -112,9 +114,20 @@ function divideRounded(value: bigint, divisor: bigint): bigint {
|
|
|
112
114
|
return (value + divisor / 2n) / divisor;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
function calculateCostUnits(
|
|
117
|
+
function calculateCostUnits(
|
|
118
|
+
inputTokens: number,
|
|
119
|
+
outputTokens: number,
|
|
120
|
+
promptCacheTokens: number,
|
|
121
|
+
price: PriceSnapshot,
|
|
122
|
+
): bigint {
|
|
123
|
+
const cachedInputTokens = Math.min(Math.max(promptCacheTokens, 0), inputTokens);
|
|
124
|
+
const uncachedInputTokens = inputTokens - cachedInputTokens;
|
|
116
125
|
const input = divideRounded(
|
|
117
|
-
BigInt(
|
|
126
|
+
BigInt(uncachedInputTokens) * decimalUnits(price.inputPricePerMillionTokens, PRICE_SCALE),
|
|
127
|
+
PRICE_TO_COST_DIVISOR,
|
|
128
|
+
);
|
|
129
|
+
const cacheInput = divideRounded(
|
|
130
|
+
BigInt(cachedInputTokens) * decimalUnits(price.cacheInputPricePerMillionTokens, PRICE_SCALE),
|
|
118
131
|
PRICE_TO_COST_DIVISOR,
|
|
119
132
|
);
|
|
120
133
|
const output = divideRounded(
|
|
@@ -122,11 +135,16 @@ function calculateCostUnits(inputTokens: number, outputTokens: number, price: Pr
|
|
|
122
135
|
PRICE_TO_COST_DIVISOR,
|
|
123
136
|
);
|
|
124
137
|
const fixed = divideRounded(decimalUnits(price.fixedCostPerRequest, PRICE_SCALE), 100n);
|
|
125
|
-
return input + output + fixed;
|
|
138
|
+
return input + cacheInput + output + fixed;
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
export function calculateLlmCost(
|
|
129
|
-
|
|
141
|
+
export function calculateLlmCost(
|
|
142
|
+
inputTokens: number,
|
|
143
|
+
outputTokens: number,
|
|
144
|
+
price: PriceSnapshot,
|
|
145
|
+
promptCacheTokens = 0,
|
|
146
|
+
): string {
|
|
147
|
+
return formatUnits(calculateCostUnits(inputTokens, outputTokens, promptCacheTokens, price), COST_SCALE);
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
|
@@ -184,6 +202,7 @@ async function findPrice(
|
|
|
184
202
|
id: valueOf(price, 'id'),
|
|
185
203
|
currency: valueOf<string>(price, 'currency'),
|
|
186
204
|
inputPricePerMillionTokens: decimalString(valueOf(price, 'inputPricePerMillionTokens'), PRICE_SCALE),
|
|
205
|
+
cacheInputPricePerMillionTokens: decimalString(valueOf(price, 'cacheInputPricePerMillionTokens'), PRICE_SCALE),
|
|
187
206
|
outputPricePerMillionTokens: decimalString(valueOf(price, 'outputPricePerMillionTokens'), PRICE_SCALE),
|
|
188
207
|
fixedCostPerRequest: decimalString(valueOf(price, 'fixedCostPerRequest'), PRICE_SCALE),
|
|
189
208
|
};
|
|
@@ -325,7 +344,10 @@ export async function finalizeLlmBilling(
|
|
|
325
344
|
costStatus = billing.price ? 'usage_unavailable' : 'unpriced';
|
|
326
345
|
}
|
|
327
346
|
|
|
328
|
-
const cost =
|
|
347
|
+
const cost =
|
|
348
|
+
numbers && billing.price
|
|
349
|
+
? calculateLlmCost(numbers.input, numbers.output, billing.price, providerUsage?.prompt_cache_tokens ?? 0)
|
|
350
|
+
: undefined;
|
|
329
351
|
const reservation = billing.reservation;
|
|
330
352
|
if (reservation) {
|
|
331
353
|
const Bucket = ctx.db.getModel('aiApiGroupQuotaBuckets');
|
|
@@ -378,6 +400,7 @@ export async function finalizeLlmBilling(
|
|
|
378
400
|
groupId: reservation?.groupId,
|
|
379
401
|
quotaMode: reservation?.quotaMode,
|
|
380
402
|
inputPricePerMillionTokens: billing.price?.inputPricePerMillionTokens,
|
|
403
|
+
cacheInputPricePerMillionTokens: billing.price?.cacheInputPricePerMillionTokens,
|
|
381
404
|
outputPricePerMillionTokens: billing.price?.outputPricePerMillionTokens,
|
|
382
405
|
fixedCostPerRequest: billing.price?.fixedCostPerRequest,
|
|
383
406
|
};
|