plugin-ai-api 1.0.15 → 1.0.21
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/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/757.a01403fb7a1bea01.js +10 -0
- package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
- package/dist/client/97.72979a11a067a7c9.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
- package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/97.29c663318eebbd57.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +36 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +105 -10
- package/dist/locale/vi-VN.json +105 -0
- package/dist/locale/zh-CN.json +105 -10
- package/dist/server/billing.js +331 -0
- package/dist/server/collections/ai-api-config.js +18 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-model-prices.js +55 -0
- package/dist/server/collections/ai-api-usage-records.js +9 -0
- package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
- package/dist/server/plugin.js +36 -3
- package/dist/server/resource/ai-api-config.js +25 -0
- package/dist/server/resource/ai-api-usage-monitor.js +86 -0
- package/dist/server/routes/agent-completions.js +62 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +157 -6
- package/dist/server/routes/completions.js +20 -3
- package/dist/server/routes/models.js +78 -20
- package/dist/server/routes/router.js +108 -23
- package/dist/server/usage.js +19 -2
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/validation.js +120 -0
- package/dist/swagger.js +32 -1
- package/package.json +1 -1
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +82 -48
- package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -0
- package/src/client-v2/pages/GeneralPage.tsx +183 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UsagePage.tsx +248 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
- package/src/client-v2/pages/api.ts +16 -0
- package/src/client-v2/plugin.tsx +62 -4
- package/src/constants.ts +21 -0
- package/src/locale/en-US.json +105 -10
- package/src/locale/vi-VN.json +105 -0
- package/src/locale/zh-CN.json +105 -10
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/billing-quota.test.ts +134 -0
- package/src/server/__tests__/billing.test.ts +33 -0
- package/src/server/__tests__/models.test.ts +74 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-monitor.test.ts +63 -0
- package/src/server/__tests__/usage-route.test.ts +4 -0
- package/src/server/billing.ts +387 -0
- package/src/server/collections/ai-api-config.ts +69 -51
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-model-prices.ts +25 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -0
- package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
- package/src/server/plugin.ts +47 -5
- package/src/server/resource/ai-api-config.ts +105 -74
- package/src/server/resource/ai-api-usage-monitor.ts +74 -0
- package/src/server/routes/agent-completions.ts +77 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +275 -6
- package/src/server/routes/completions.ts +27 -4
- package/src/server/routes/models.ts +290 -195
- package/src/server/routes/router.ts +152 -27
- package/src/server/usage.ts +19 -1
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/validation.ts +89 -0
- package/src/swagger.ts +38 -1
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client/950.83390c5f1d5a97fb.js +0 -10
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { Context } from '@nocobase/actions';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import utc from 'dayjs/plugin/utc';
|
|
4
|
+
import timezone from 'dayjs/plugin/timezone';
|
|
5
|
+
import type { Model } from '@nocobase/database';
|
|
6
|
+
import type { Transaction } from 'sequelize';
|
|
7
|
+
import type { Usage } from './usage';
|
|
8
|
+
|
|
9
|
+
dayjs.extend(utc);
|
|
10
|
+
dayjs.extend(timezone);
|
|
11
|
+
|
|
12
|
+
const PRICE_SCALE = 10;
|
|
13
|
+
const COST_SCALE = 8;
|
|
14
|
+
const PRICE_TO_COST_DIVISOR = 100_000_000n;
|
|
15
|
+
|
|
16
|
+
export interface ResolvedLlmModel {
|
|
17
|
+
service: Model | Record<string, unknown>;
|
|
18
|
+
modelId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PriceSnapshot {
|
|
22
|
+
id: string | number | bigint;
|
|
23
|
+
currency: string;
|
|
24
|
+
inputPricePerMillionTokens: string;
|
|
25
|
+
outputPricePerMillionTokens: string;
|
|
26
|
+
fixedCostPerRequest: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface QuotaReservation {
|
|
30
|
+
bucketId: string | number | bigint;
|
|
31
|
+
policyId: string | number | bigint;
|
|
32
|
+
estimatedInputTokens: number;
|
|
33
|
+
estimatedOutputTokens: number;
|
|
34
|
+
reservedTokens: number;
|
|
35
|
+
reservedCost: string;
|
|
36
|
+
missingUsageBehavior: 'allow' | 'use_reserved';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LlmBillingState {
|
|
40
|
+
resolution?: {
|
|
41
|
+
service: string;
|
|
42
|
+
provider: string;
|
|
43
|
+
model: string;
|
|
44
|
+
};
|
|
45
|
+
price?: PriceSnapshot;
|
|
46
|
+
reservation?: QuotaReservation;
|
|
47
|
+
providerAttempted?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BillingFinalization {
|
|
51
|
+
usage?: Usage;
|
|
52
|
+
estimatedCost?: string;
|
|
53
|
+
currency?: string;
|
|
54
|
+
costStatus?: 'calculated' | 'estimated' | 'unpriced' | 'usage_unavailable';
|
|
55
|
+
modelPriceId?: string | number | bigint;
|
|
56
|
+
quotaPolicyId?: string | number | bigint;
|
|
57
|
+
inputPricePerMillionTokens?: string;
|
|
58
|
+
outputPricePerMillionTokens?: string;
|
|
59
|
+
fixedCostPerRequest?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface BillingContextState {
|
|
63
|
+
aiApiLlmBilling?: LlmBillingState;
|
|
64
|
+
currentUser?: { id?: string | number | bigint };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class AiApiQuotaError extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
public readonly code: string,
|
|
70
|
+
message: string,
|
|
71
|
+
) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'AiApiQuotaError';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stateOf(ctx: Context): BillingContextState {
|
|
78
|
+
return ctx.state as BillingContextState;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function valueOf<T>(model: Model | Record<string, unknown> | null | undefined, name: string): T {
|
|
82
|
+
if (!model) return undefined as T;
|
|
83
|
+
if (typeof (model as Model).get === 'function') return (model as Model).get(name) as T;
|
|
84
|
+
return (model as Record<string, unknown>)[name] as T;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function decimalString(value: unknown, scale: number): string {
|
|
88
|
+
const source = String(value ?? '0').trim();
|
|
89
|
+
const match = source.match(/^(-?)(\d+)(?:\.(\d+))?$/);
|
|
90
|
+
if (!match) throw new Error(`Invalid decimal value: ${source}`);
|
|
91
|
+
const fraction = (match[3] ?? '').padEnd(scale, '0').slice(0, scale);
|
|
92
|
+
return `${match[1]}${match[2]}.${fraction}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function decimalUnits(value: unknown, scale: number): bigint {
|
|
96
|
+
return BigInt(decimalString(value, scale).replace('.', ''));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatUnits(value: bigint, scale: number): string {
|
|
100
|
+
const sign = value < 0n ? '-' : '';
|
|
101
|
+
const digits = (value < 0n ? -value : value).toString().padStart(scale + 1, '0');
|
|
102
|
+
if (scale === 0) return `${sign}${digits}`;
|
|
103
|
+
return `${sign}${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function divideRounded(value: bigint, divisor: bigint): bigint {
|
|
107
|
+
return (value + divisor / 2n) / divisor;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function calculateCostUnits(inputTokens: number, outputTokens: number, price: PriceSnapshot): bigint {
|
|
111
|
+
const input = divideRounded(
|
|
112
|
+
BigInt(inputTokens) * decimalUnits(price.inputPricePerMillionTokens, PRICE_SCALE),
|
|
113
|
+
PRICE_TO_COST_DIVISOR,
|
|
114
|
+
);
|
|
115
|
+
const output = divideRounded(
|
|
116
|
+
BigInt(outputTokens) * decimalUnits(price.outputPricePerMillionTokens, PRICE_SCALE),
|
|
117
|
+
PRICE_TO_COST_DIVISOR,
|
|
118
|
+
);
|
|
119
|
+
const fixed = divideRounded(decimalUnits(price.fixedCostPerRequest, PRICE_SCALE), 100n);
|
|
120
|
+
return input + output + fixed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function calculateLlmCost(inputTokens: number, outputTokens: number, price: PriceSnapshot): string {
|
|
124
|
+
return formatUnits(calculateCostUnits(inputTokens, outputTokens, price), COST_SCALE);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
|
128
|
+
const parsed = Number(value);
|
|
129
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function estimateInputTokens(ctx: Context): number {
|
|
133
|
+
const body = (ctx.request.body ?? {}) as Record<string, unknown>;
|
|
134
|
+
const input = body.messages ?? body.prompt ?? '';
|
|
135
|
+
return Math.max(1, Math.ceil(JSON.stringify(input).length / 4));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getPeriodBounds(periodType: string, timezone: string): { start: Date; end: Date } {
|
|
139
|
+
const unit = periodType === 'daily' ? 'day' : 'month';
|
|
140
|
+
try {
|
|
141
|
+
const start = dayjs()
|
|
142
|
+
.tz(timezone || 'UTC')
|
|
143
|
+
.startOf(unit);
|
|
144
|
+
return { start: start.utc().toDate(), end: start.add(1, unit).utc().toDate() };
|
|
145
|
+
} catch {
|
|
146
|
+
const start = dayjs().utc().startOf(unit);
|
|
147
|
+
return { start: start.toDate(), end: start.add(1, unit).toDate() };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function exceedsIntegerLimit(current: bigint, added: bigint, limit: unknown): boolean {
|
|
152
|
+
if (limit === null || limit === undefined || limit === '') return false;
|
|
153
|
+
return current + added > BigInt(String(limit));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function exceedsDecimalLimit(current: bigint, added: bigint, limit: unknown): boolean {
|
|
157
|
+
if (limit === null || limit === undefined || limit === '') return false;
|
|
158
|
+
return current + added > decimalUnits(limit, COST_SCALE);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function findPrice(
|
|
162
|
+
ctx: Context,
|
|
163
|
+
service: Model | Record<string, unknown>,
|
|
164
|
+
modelId: string,
|
|
165
|
+
): Promise<PriceSnapshot | undefined> {
|
|
166
|
+
const now = new Date();
|
|
167
|
+
const price = await ctx.db.getRepository('aiApiModelPrices').findOne({
|
|
168
|
+
filter: {
|
|
169
|
+
llmService: valueOf<string>(service, 'name'),
|
|
170
|
+
model: modelId,
|
|
171
|
+
enabled: true,
|
|
172
|
+
effectiveFrom: { $lte: now },
|
|
173
|
+
$or: [{ effectiveTo: null }, { effectiveTo: { $gt: now } }],
|
|
174
|
+
},
|
|
175
|
+
sort: '-effectiveFrom',
|
|
176
|
+
});
|
|
177
|
+
if (!price) return undefined;
|
|
178
|
+
return {
|
|
179
|
+
id: valueOf(price, 'id'),
|
|
180
|
+
currency: valueOf<string>(price, 'currency'),
|
|
181
|
+
inputPricePerMillionTokens: decimalString(valueOf(price, 'inputPricePerMillionTokens'), PRICE_SCALE),
|
|
182
|
+
outputPricePerMillionTokens: decimalString(valueOf(price, 'outputPricePerMillionTokens'), PRICE_SCALE),
|
|
183
|
+
fixedCostPerRequest: decimalString(valueOf(price, 'fixedCostPerRequest'), PRICE_SCALE),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function prepareLlmBilling(ctx: Context, resolved: ResolvedLlmModel): Promise<void> {
|
|
188
|
+
const userId = stateOf(ctx).currentUser?.id;
|
|
189
|
+
const serviceName = valueOf<string>(resolved.service, 'name');
|
|
190
|
+
const provider = valueOf<string>(resolved.service, 'provider');
|
|
191
|
+
const price = await findPrice(ctx, resolved.service, resolved.modelId);
|
|
192
|
+
const billing: LlmBillingState = {
|
|
193
|
+
resolution: { service: serviceName, provider, model: resolved.modelId },
|
|
194
|
+
price,
|
|
195
|
+
};
|
|
196
|
+
stateOf(ctx).aiApiLlmBilling = billing;
|
|
197
|
+
|
|
198
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
199
|
+
if (!valueOf<boolean | undefined>(config, 'quotaEnabled') || userId === undefined || userId === null) return;
|
|
200
|
+
|
|
201
|
+
const policy = await ctx.db.getRepository('aiApiUserQuotaPolicies').findOne({
|
|
202
|
+
filter: { userId, enabled: true },
|
|
203
|
+
sort: '-updatedAt',
|
|
204
|
+
});
|
|
205
|
+
if (!policy) return;
|
|
206
|
+
|
|
207
|
+
const rejectUnpriced = valueOf<boolean>(policy, 'rejectUnpricedModel');
|
|
208
|
+
if (!price && rejectUnpriced) {
|
|
209
|
+
throw new AiApiQuotaError(
|
|
210
|
+
'model_price_not_configured',
|
|
211
|
+
`Pricing is not configured for '${serviceName}/${resolved.modelId}'.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const policyCurrency = valueOf<string>(policy, 'currency');
|
|
215
|
+
if (price && policyCurrency !== price.currency) {
|
|
216
|
+
throw new AiApiQuotaError(
|
|
217
|
+
'quota_currency_mismatch',
|
|
218
|
+
`Quota currency '${policyCurrency}' does not match model price currency '${price.currency}'.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const body = (ctx.request.body ?? {}) as Record<string, unknown>;
|
|
223
|
+
const estimatedInputTokens = estimateInputTokens(ctx);
|
|
224
|
+
const defaultOutput = normalizePositiveInteger(valueOf(config, 'defaultReservationOutputTokens'), 4096);
|
|
225
|
+
const estimatedOutputTokens = normalizePositiveInteger(body.max_completion_tokens ?? body.max_tokens, defaultOutput);
|
|
226
|
+
const reservedTokens = estimatedInputTokens + estimatedOutputTokens;
|
|
227
|
+
const reservedCost = price ? calculateLlmCost(estimatedInputTokens, estimatedOutputTokens, price) : '0.00000000';
|
|
228
|
+
const period = getPeriodBounds(valueOf<string>(policy, 'periodType'), valueOf<string>(policy, 'timezone'));
|
|
229
|
+
const Bucket = ctx.db.getModel('aiApiUserQuotaBuckets');
|
|
230
|
+
|
|
231
|
+
const reservation = await ctx.db.sequelize.transaction(async (transaction: Transaction) => {
|
|
232
|
+
const [bucket] = await Bucket.findOrCreate({
|
|
233
|
+
where: { policyId: valueOf(policy, 'id'), periodStart: period.start },
|
|
234
|
+
defaults: {
|
|
235
|
+
userId,
|
|
236
|
+
periodEnd: period.end,
|
|
237
|
+
requestCount: 0,
|
|
238
|
+
totalTokens: 0,
|
|
239
|
+
cost: '0.00000000',
|
|
240
|
+
reservedRequests: 0,
|
|
241
|
+
reservedTokens: 0,
|
|
242
|
+
reservedCost: '0.00000000',
|
|
243
|
+
},
|
|
244
|
+
transaction,
|
|
245
|
+
});
|
|
246
|
+
await bucket.reload({ transaction, lock: transaction.LOCK.UPDATE });
|
|
247
|
+
|
|
248
|
+
const requestCount = BigInt(String(bucket.get('requestCount') ?? 0));
|
|
249
|
+
const reservedRequests = BigInt(String(bucket.get('reservedRequests') ?? 0));
|
|
250
|
+
if (exceedsIntegerLimit(requestCount + reservedRequests, 1n, valueOf(policy, 'requestLimit'))) {
|
|
251
|
+
throw new AiApiQuotaError('request_quota_exceeded', 'The request quota for this user has been exceeded.');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const totalTokens = BigInt(String(bucket.get('totalTokens') ?? 0));
|
|
255
|
+
const alreadyReservedTokens = BigInt(String(bucket.get('reservedTokens') ?? 0));
|
|
256
|
+
if (
|
|
257
|
+
exceedsIntegerLimit(
|
|
258
|
+
totalTokens + alreadyReservedTokens,
|
|
259
|
+
BigInt(reservedTokens),
|
|
260
|
+
valueOf(policy, 'totalTokenLimit'),
|
|
261
|
+
)
|
|
262
|
+
) {
|
|
263
|
+
throw new AiApiQuotaError('token_quota_exceeded', 'The token quota for this user has been exceeded.');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const cost = decimalUnits(bucket.get('cost'), COST_SCALE);
|
|
267
|
+
const alreadyReservedCost = decimalUnits(bucket.get('reservedCost'), COST_SCALE);
|
|
268
|
+
if (
|
|
269
|
+
exceedsDecimalLimit(
|
|
270
|
+
cost + alreadyReservedCost,
|
|
271
|
+
decimalUnits(reservedCost, COST_SCALE),
|
|
272
|
+
valueOf(policy, 'costLimit'),
|
|
273
|
+
)
|
|
274
|
+
) {
|
|
275
|
+
throw new AiApiQuotaError('cost_quota_exceeded', 'The cost quota for this user has been exceeded.');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await bucket.update(
|
|
279
|
+
{
|
|
280
|
+
reservedRequests: formatUnits(reservedRequests + 1n, 0),
|
|
281
|
+
reservedTokens: formatUnits(alreadyReservedTokens + BigInt(reservedTokens), 0),
|
|
282
|
+
reservedCost: formatUnits(alreadyReservedCost + decimalUnits(reservedCost, COST_SCALE), COST_SCALE),
|
|
283
|
+
},
|
|
284
|
+
{ transaction },
|
|
285
|
+
);
|
|
286
|
+
return {
|
|
287
|
+
bucketId: bucket.get('id') as string | number | bigint,
|
|
288
|
+
policyId: valueOf<string | number | bigint>(policy, 'id'),
|
|
289
|
+
estimatedInputTokens,
|
|
290
|
+
estimatedOutputTokens,
|
|
291
|
+
reservedTokens,
|
|
292
|
+
reservedCost,
|
|
293
|
+
missingUsageBehavior:
|
|
294
|
+
valueOf<string>(policy, 'missingUsageBehavior') === 'allow' ? ('allow' as const) : ('use_reserved' as const),
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
billing.reservation = reservation;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function markLlmProviderAttempted(ctx: Context): void {
|
|
301
|
+
const state = stateOf(ctx);
|
|
302
|
+
state.aiApiLlmBilling = { ...(state.aiApiLlmBilling ?? {}), providerAttempted: true };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function usageNumbers(usage: Usage | undefined): { input: number; output: number; total: number } | undefined {
|
|
306
|
+
if (!usage || usage.prompt_tokens === null || usage.completion_tokens === null) return undefined;
|
|
307
|
+
return {
|
|
308
|
+
input: usage.prompt_tokens,
|
|
309
|
+
output: usage.completion_tokens,
|
|
310
|
+
total: usage.total_tokens ?? usage.prompt_tokens + usage.completion_tokens,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function finalizeLlmBilling(
|
|
315
|
+
ctx: Context,
|
|
316
|
+
providerUsage: Usage | undefined,
|
|
317
|
+
succeeded: boolean,
|
|
318
|
+
): Promise<BillingFinalization> {
|
|
319
|
+
const billing = stateOf(ctx).aiApiLlmBilling;
|
|
320
|
+
if (!billing) return {};
|
|
321
|
+
|
|
322
|
+
let numbers = usageNumbers(providerUsage);
|
|
323
|
+
let costStatus: BillingFinalization['costStatus'];
|
|
324
|
+
if (numbers) {
|
|
325
|
+
costStatus = billing.price ? 'calculated' : 'unpriced';
|
|
326
|
+
} else if (succeeded && billing.reservation?.missingUsageBehavior === 'use_reserved') {
|
|
327
|
+
numbers = {
|
|
328
|
+
input: billing.reservation.estimatedInputTokens,
|
|
329
|
+
output: billing.reservation.estimatedOutputTokens,
|
|
330
|
+
total: billing.reservation.reservedTokens,
|
|
331
|
+
};
|
|
332
|
+
costStatus = billing.price ? 'estimated' : 'unpriced';
|
|
333
|
+
} else {
|
|
334
|
+
costStatus = billing.price ? 'usage_unavailable' : 'unpriced';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const cost = numbers && billing.price ? calculateLlmCost(numbers.input, numbers.output, billing.price) : undefined;
|
|
338
|
+
const reservation = billing.reservation;
|
|
339
|
+
if (reservation) {
|
|
340
|
+
const Bucket = ctx.db.getModel('aiApiUserQuotaBuckets');
|
|
341
|
+
await ctx.db.sequelize.transaction(async (transaction: Transaction) => {
|
|
342
|
+
const bucket = await Bucket.findByPk(reservation.bucketId, { transaction, lock: transaction.LOCK.UPDATE });
|
|
343
|
+
if (!bucket) return;
|
|
344
|
+
const reservedRequests = BigInt(String(bucket.get('reservedRequests') ?? 0));
|
|
345
|
+
const reservedTokens = BigInt(String(bucket.get('reservedTokens') ?? 0));
|
|
346
|
+
const reservedCost = decimalUnits(bucket.get('reservedCost'), COST_SCALE);
|
|
347
|
+
const requestCount = BigInt(String(bucket.get('requestCount') ?? 0));
|
|
348
|
+
const totalTokens = BigInt(String(bucket.get('totalTokens') ?? 0));
|
|
349
|
+
const currentCost = decimalUnits(bucket.get('cost'), COST_SCALE);
|
|
350
|
+
await bucket.update(
|
|
351
|
+
{
|
|
352
|
+
reservedRequests: formatUnits(reservedRequests > 0n ? reservedRequests - 1n : 0n, 0),
|
|
353
|
+
reservedTokens: formatUnits(
|
|
354
|
+
reservedTokens >= BigInt(reservation.reservedTokens)
|
|
355
|
+
? reservedTokens - BigInt(reservation.reservedTokens)
|
|
356
|
+
: 0n,
|
|
357
|
+
0,
|
|
358
|
+
),
|
|
359
|
+
reservedCost: formatUnits(
|
|
360
|
+
reservedCost >= decimalUnits(reservation.reservedCost, COST_SCALE)
|
|
361
|
+
? reservedCost - decimalUnits(reservation.reservedCost, COST_SCALE)
|
|
362
|
+
: 0n,
|
|
363
|
+
COST_SCALE,
|
|
364
|
+
),
|
|
365
|
+
requestCount: formatUnits(requestCount + (billing.providerAttempted ? 1n : 0n), 0),
|
|
366
|
+
totalTokens: formatUnits(totalTokens + BigInt(numbers?.total ?? 0), 0),
|
|
367
|
+
cost: formatUnits(currentCost + decimalUnits(cost ?? '0', COST_SCALE), COST_SCALE),
|
|
368
|
+
},
|
|
369
|
+
{ transaction },
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
usage: numbers
|
|
376
|
+
? { prompt_tokens: numbers.input, completion_tokens: numbers.output, total_tokens: numbers.total }
|
|
377
|
+
: providerUsage,
|
|
378
|
+
estimatedCost: cost,
|
|
379
|
+
currency: billing.price?.currency,
|
|
380
|
+
costStatus,
|
|
381
|
+
modelPriceId: billing.price?.id,
|
|
382
|
+
quotaPolicyId: reservation?.policyId,
|
|
383
|
+
inputPricePerMillionTokens: billing.price?.inputPricePerMillionTokens,
|
|
384
|
+
outputPricePerMillionTokens: billing.price?.outputPricePerMillionTokens,
|
|
385
|
+
fixedCostPerRequest: billing.price?.fixedCostPerRequest,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
@@ -1,51 +1,69 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file is part of the NocoBase (R) project.
|
|
3
|
-
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
-
* Authors: NocoBase Team.
|
|
5
|
-
*
|
|
6
|
-
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
-
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { defineCollection } from '@nocobase/database';
|
|
11
|
-
|
|
12
|
-
export default defineCollection({
|
|
13
|
-
name: 'aiApiConfig',
|
|
14
|
-
autoGenId: true,
|
|
15
|
-
fields: [
|
|
16
|
-
{
|
|
17
|
-
name: 'mode',
|
|
18
|
-
type: 'string',
|
|
19
|
-
defaultValue: 'llm',
|
|
20
|
-
comment: "API mode: 'llm' = direct LLM proxy, 'agent' = full AI Employee agent with tools/RAG",
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
name: 'defaultAiEmployee',
|
|
24
|
-
type: 'string',
|
|
25
|
-
comment: 'Username of the default AI Employee for system prompt injection',
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
name: 'defaultLlmService',
|
|
29
|
-
type: 'string',
|
|
30
|
-
comment: 'Name (UID) of the default LLM service. Clients can send just modelId without service prefix.',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: 'enabledLlmServices',
|
|
34
|
-
type: 'json',
|
|
35
|
-
defaultValue: [],
|
|
36
|
-
comment: 'Array of llmService names to expose. Empty = expose all enabled services',
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
name: 'rateLimitPerMinute',
|
|
40
|
-
type: 'integer',
|
|
41
|
-
defaultValue: 60,
|
|
42
|
-
comment: 'Max requests per user per minute',
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: '
|
|
46
|
-
type: '
|
|
47
|
-
defaultValue:
|
|
48
|
-
comment: '
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineCollection } from '@nocobase/database';
|
|
11
|
+
|
|
12
|
+
export default defineCollection({
|
|
13
|
+
name: 'aiApiConfig',
|
|
14
|
+
autoGenId: true,
|
|
15
|
+
fields: [
|
|
16
|
+
{
|
|
17
|
+
name: 'mode',
|
|
18
|
+
type: 'string',
|
|
19
|
+
defaultValue: 'llm',
|
|
20
|
+
comment: "API mode: 'llm' = direct LLM proxy, 'agent' = full AI Employee agent with tools/RAG",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'defaultAiEmployee',
|
|
24
|
+
type: 'string',
|
|
25
|
+
comment: 'Username of the default AI Employee for system prompt injection',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'defaultLlmService',
|
|
29
|
+
type: 'string',
|
|
30
|
+
comment: 'Name (UID) of the default LLM service. Clients can send just modelId without service prefix.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'enabledLlmServices',
|
|
34
|
+
type: 'json',
|
|
35
|
+
defaultValue: [],
|
|
36
|
+
comment: 'Array of llmService names to expose. Empty = expose all enabled services',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'rateLimitPerMinute',
|
|
40
|
+
type: 'integer',
|
|
41
|
+
defaultValue: 60,
|
|
42
|
+
comment: 'Max requests per user per minute',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'maxRequestBodyMb',
|
|
46
|
+
type: 'integer',
|
|
47
|
+
defaultValue: 10,
|
|
48
|
+
comment: 'Max request body size in MB. Raise this to accept inline base64 images in vision requests.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'quotaEnabled',
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
defaultValue: false,
|
|
54
|
+
comment: 'Enable per-user request, token, and cost quotas for direct LLM mode',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'defaultReservationOutputTokens',
|
|
58
|
+
type: 'integer',
|
|
59
|
+
defaultValue: 4096,
|
|
60
|
+
comment: 'Output tokens reserved when a request does not specify a maximum',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'options',
|
|
64
|
+
type: 'jsonb',
|
|
65
|
+
defaultValue: {},
|
|
66
|
+
comment: 'Reserved for future extensibility',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineCollection } from '@nocobase/database';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-model metadata overrides surfaced through the OpenAI-compatible GET /v1/models
|
|
14
|
+
* endpoint. Lets an admin correct or supply values that the upstream provider either
|
|
15
|
+
* reports incorrectly (e.g. an inflated context window) or omits entirely.
|
|
16
|
+
*
|
|
17
|
+
* A row is keyed by (llmService, model). All override fields are nullable — a null
|
|
18
|
+
* value means "do not override this attribute", so an admin can override just the
|
|
19
|
+
* context window without touching anything else.
|
|
20
|
+
*/
|
|
21
|
+
export default defineCollection({
|
|
22
|
+
name: 'aiApiModelMetadata',
|
|
23
|
+
autoGenId: true,
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: 'llmService', type: 'string', allowNull: false, index: true },
|
|
26
|
+
{ name: 'model', type: 'string', allowNull: false, index: true },
|
|
27
|
+
{
|
|
28
|
+
name: 'contextWindow',
|
|
29
|
+
type: 'integer',
|
|
30
|
+
allowNull: true,
|
|
31
|
+
comment:
|
|
32
|
+
'Override for the model context window (input+output token capacity). Returned as context_window / context_length.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'maxCompletionTokens',
|
|
36
|
+
type: 'integer',
|
|
37
|
+
allowNull: true,
|
|
38
|
+
comment: 'Override for the maximum output tokens. Returned as max_completion_tokens.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'ownedByOverride',
|
|
42
|
+
type: 'string',
|
|
43
|
+
allowNull: true,
|
|
44
|
+
comment: 'Override for the owned_by field in the OpenAI model object.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'displayName',
|
|
48
|
+
type: 'string',
|
|
49
|
+
allowNull: true,
|
|
50
|
+
comment: 'Friendly display name returned as display_name / name in the model object.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'description',
|
|
54
|
+
type: 'text',
|
|
55
|
+
allowNull: true,
|
|
56
|
+
comment: 'Human-readable description returned as description in the model object.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'enabled',
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
defaultValue: true,
|
|
62
|
+
index: true,
|
|
63
|
+
comment: 'When false, the model is hidden from /v1/models and reported as active:false.',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
indexes: [
|
|
67
|
+
{
|
|
68
|
+
fields: ['llmService', 'model'],
|
|
69
|
+
unique: true,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineCollection } from '@nocobase/database';
|
|
2
|
+
|
|
3
|
+
export default defineCollection({
|
|
4
|
+
name: 'aiApiModelPrices',
|
|
5
|
+
autoGenId: true,
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'llmService', type: 'string', allowNull: false, index: true },
|
|
8
|
+
{ name: 'provider', type: 'string', allowNull: false, index: true },
|
|
9
|
+
{ name: 'model', type: 'string', allowNull: false, index: true },
|
|
10
|
+
{ name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
|
|
11
|
+
{ name: 'inputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
|
|
12
|
+
{ name: 'outputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
|
|
13
|
+
{ name: 'fixedCostPerRequest', type: 'decimal', precision: 20, scale: 10, allowNull: false, defaultValue: 0 },
|
|
14
|
+
{ name: 'effectiveFrom', type: 'datetimeTz', allowNull: false, index: true },
|
|
15
|
+
{ name: 'effectiveTo', type: 'datetimeTz', allowNull: true, index: true },
|
|
16
|
+
{ name: 'enabled', type: 'boolean', defaultValue: true, index: true },
|
|
17
|
+
{ name: 'notes', type: 'text', allowNull: true },
|
|
18
|
+
],
|
|
19
|
+
indexes: [
|
|
20
|
+
{
|
|
21
|
+
fields: ['llmService', 'model', 'effectiveFrom'],
|
|
22
|
+
unique: true,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
});
|
|
@@ -22,6 +22,9 @@ export default defineCollection({
|
|
|
22
22
|
{ name: 'endpoint', type: 'string' },
|
|
23
23
|
{ name: 'mode', type: 'string', allowNull: true },
|
|
24
24
|
{ name: 'model', type: 'string', allowNull: true, index: true },
|
|
25
|
+
{ name: 'resolvedService', type: 'string', allowNull: true, index: true },
|
|
26
|
+
{ name: 'resolvedProvider', type: 'string', allowNull: true, index: true },
|
|
27
|
+
{ name: 'resolvedModel', type: 'string', allowNull: true, index: true },
|
|
25
28
|
{ name: 'status', type: 'string', index: true },
|
|
26
29
|
{ name: 'httpStatus', type: 'integer', allowNull: true },
|
|
27
30
|
{ name: 'errorCode', type: 'string', allowNull: true },
|
|
@@ -31,6 +34,12 @@ export default defineCollection({
|
|
|
31
34
|
{ name: 'totalTokens', type: 'integer', allowNull: true },
|
|
32
35
|
{ name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
|
|
33
36
|
{ name: 'currency', type: 'string', allowNull: true },
|
|
37
|
+
{ name: 'costStatus', type: 'string', allowNull: true, index: true },
|
|
38
|
+
{ name: 'modelPriceId', type: 'bigInt', allowNull: true, index: true },
|
|
39
|
+
{ name: 'quotaPolicyId', type: 'bigInt', allowNull: true, index: true },
|
|
40
|
+
{ name: 'inputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
41
|
+
{ name: 'outputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
42
|
+
{ name: 'fixedCostPerRequest', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
34
43
|
{ name: 'providerRequestId', type: 'string', allowNull: true },
|
|
35
44
|
{ name: 'requestMetadata', type: 'jsonb', defaultValue: {} },
|
|
36
45
|
{ name: 'responseMetadata', type: 'jsonb', defaultValue: {} },
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineCollection } from '@nocobase/database';
|
|
2
|
+
|
|
3
|
+
export default defineCollection({
|
|
4
|
+
name: 'aiApiUserQuotaBuckets',
|
|
5
|
+
autoGenId: true,
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'policyId', type: 'bigInt', allowNull: false, index: true },
|
|
8
|
+
{ name: 'userId', type: 'bigInt', allowNull: false, index: true },
|
|
9
|
+
{ name: 'periodStart', type: 'datetimeTz', allowNull: false, index: true },
|
|
10
|
+
{ name: 'periodEnd', type: 'datetimeTz', allowNull: false, index: true },
|
|
11
|
+
{ name: 'requestCount', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
12
|
+
{ name: 'totalTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
13
|
+
{ name: 'cost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
|
|
14
|
+
{ name: 'reservedRequests', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
15
|
+
{ name: 'reservedTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
16
|
+
{ name: 'reservedCost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
|
|
17
|
+
],
|
|
18
|
+
indexes: [
|
|
19
|
+
{
|
|
20
|
+
fields: ['policyId', 'periodStart'],
|
|
21
|
+
unique: true,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|