plugin-ai-api 1.0.14 → 1.0.20
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/302.25edd5d75460acbf.js +10 -0
- package/dist/client/757.71e30f2a1306562d.js +10 -0
- package/dist/client/902.4238b04ac667c30a.js +10 -0
- package/dist/client/97.37cda285d7da3a26.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.9b27a263901d54d8.js +10 -0
- package/dist/client-v2/757.c377e2f2b054d89d.js +10 -0
- package/dist/client-v2/902.d40d7bda106124c8.js +10 -0
- package/dist/client-v2/97.fc922c37ced86831.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +9 -9
- package/dist/locale/en-US.json +78 -2
- package/dist/locale/vi-VN.json +86 -0
- package/dist/locale/zh-CN.json +86 -10
- package/dist/server/billing.js +331 -0
- package/dist/server/collections/ai-api-config.js +12 -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 +23 -2
- package/dist/server/resource/ai-api-config.js +8 -0
- package/dist/server/resource/ai-api-usage-monitor.js +86 -0
- package/dist/server/routes/chat-completions.js +12 -2
- package/dist/server/routes/completions.js +12 -2
- package/dist/server/routes/router.js +14 -1
- package/dist/server/usage.js +17 -2
- package/dist/server/validation.js +102 -0
- package/package.json +1 -1
- package/src/client/plugin.tsx +73 -48
- package/src/client-v2/locale.ts +1 -0
- package/src/client-v2/pages/GeneralPage.tsx +170 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +285 -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 +21 -3
- package/src/locale/en-US.json +78 -2
- package/src/locale/vi-VN.json +86 -0
- package/src/locale/zh-CN.json +86 -10
- package/src/server/__tests__/billing-quota.test.ts +134 -0
- package/src/server/__tests__/billing.test.ts +33 -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 +63 -51
- 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 +24 -2
- package/src/server/resource/ai-api-config.ts +82 -74
- package/src/server/resource/ai-api-usage-monitor.ts +74 -0
- package/src/server/routes/chat-completions.ts +13 -2
- package/src/server/routes/completions.ts +13 -2
- package/src/server/routes/router.ts +16 -1
- package/src/server/usage.ts +17 -1
- package/src/server/validation.ts +62 -0
- package/dist/client/950.83390c5f1d5a97fb.js +0 -10
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import { createMockDatabase, type Database } from '@nocobase/database';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { AiApiQuotaError, finalizeLlmBilling, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
5
|
+
|
|
6
|
+
describe('AI API user quota reservation', () => {
|
|
7
|
+
let db: Database;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
db = await createMockDatabase();
|
|
11
|
+
db.collection({
|
|
12
|
+
name: 'aiApiConfig',
|
|
13
|
+
fields: [
|
|
14
|
+
{ name: 'quotaEnabled', type: 'boolean' },
|
|
15
|
+
{ name: 'defaultReservationOutputTokens', type: 'integer' },
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
db.collection({
|
|
19
|
+
name: 'aiApiModelPrices',
|
|
20
|
+
fields: [
|
|
21
|
+
{ name: 'llmService', type: 'string' },
|
|
22
|
+
{ name: 'model', type: 'string' },
|
|
23
|
+
{ name: 'enabled', type: 'boolean' },
|
|
24
|
+
{ name: 'currency', type: 'string' },
|
|
25
|
+
{ name: 'inputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10 },
|
|
26
|
+
{ name: 'outputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10 },
|
|
27
|
+
{ name: 'fixedCostPerRequest', type: 'decimal', precision: 20, scale: 10 },
|
|
28
|
+
{ name: 'effectiveFrom', type: 'datetimeTz' },
|
|
29
|
+
{ name: 'effectiveTo', type: 'datetimeTz' },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
db.collection({
|
|
33
|
+
name: 'aiApiUserQuotaPolicies',
|
|
34
|
+
fields: [
|
|
35
|
+
{ name: 'userId', type: 'bigInt' },
|
|
36
|
+
{ name: 'enabled', type: 'boolean' },
|
|
37
|
+
{ name: 'periodType', type: 'string' },
|
|
38
|
+
{ name: 'timezone', type: 'string' },
|
|
39
|
+
{ name: 'requestLimit', type: 'bigInt' },
|
|
40
|
+
{ name: 'totalTokenLimit', type: 'bigInt' },
|
|
41
|
+
{ name: 'costLimit', type: 'decimal', precision: 20, scale: 8 },
|
|
42
|
+
{ name: 'currency', type: 'string' },
|
|
43
|
+
{ name: 'rejectUnpricedModel', type: 'boolean' },
|
|
44
|
+
{ name: 'missingUsageBehavior', type: 'string' },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
db.collection({
|
|
48
|
+
name: 'aiApiUserQuotaBuckets',
|
|
49
|
+
fields: [
|
|
50
|
+
{ name: 'policyId', type: 'bigInt' },
|
|
51
|
+
{ name: 'userId', type: 'bigInt' },
|
|
52
|
+
{ name: 'periodStart', type: 'datetimeTz' },
|
|
53
|
+
{ name: 'periodEnd', type: 'datetimeTz' },
|
|
54
|
+
{ name: 'requestCount', type: 'bigInt' },
|
|
55
|
+
{ name: 'totalTokens', type: 'bigInt' },
|
|
56
|
+
{ name: 'cost', type: 'decimal', precision: 20, scale: 8 },
|
|
57
|
+
{ name: 'reservedRequests', type: 'bigInt' },
|
|
58
|
+
{ name: 'reservedTokens', type: 'bigInt' },
|
|
59
|
+
{ name: 'reservedCost', type: 'decimal', precision: 20, scale: 8 },
|
|
60
|
+
],
|
|
61
|
+
indexes: [{ fields: ['policyId', 'periodStart'], unique: true }],
|
|
62
|
+
});
|
|
63
|
+
await db.sync({ force: true });
|
|
64
|
+
await db.getRepository('aiApiConfig').create({
|
|
65
|
+
values: { quotaEnabled: true, defaultReservationOutputTokens: 100 },
|
|
66
|
+
});
|
|
67
|
+
await db.getRepository('aiApiModelPrices').create({
|
|
68
|
+
values: {
|
|
69
|
+
llmService: 'service-a',
|
|
70
|
+
model: 'model-a',
|
|
71
|
+
enabled: true,
|
|
72
|
+
currency: 'USD',
|
|
73
|
+
inputPricePerMillionTokens: '5',
|
|
74
|
+
outputPricePerMillionTokens: '15',
|
|
75
|
+
fixedCostPerRequest: '0',
|
|
76
|
+
effectiveFrom: new Date('2020-01-01T00:00:00Z'),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
await db.getRepository('aiApiUserQuotaPolicies').create({
|
|
80
|
+
values: {
|
|
81
|
+
userId: 7,
|
|
82
|
+
enabled: true,
|
|
83
|
+
periodType: 'monthly',
|
|
84
|
+
timezone: 'UTC',
|
|
85
|
+
requestLimit: 1,
|
|
86
|
+
totalTokenLimit: 1000,
|
|
87
|
+
costLimit: '10',
|
|
88
|
+
currency: 'USD',
|
|
89
|
+
rejectUnpricedModel: true,
|
|
90
|
+
missingUsageBehavior: 'use_reserved',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await db.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function context(): Context {
|
|
100
|
+
return {
|
|
101
|
+
db,
|
|
102
|
+
request: { body: { messages: [{ role: 'user', content: 'hello' }], max_tokens: 100 } },
|
|
103
|
+
state: { currentUser: { id: 7 } },
|
|
104
|
+
} as unknown as Context;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const resolved = {
|
|
108
|
+
service: { name: 'service-a', provider: 'custom-llm' },
|
|
109
|
+
modelId: 'model-a',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
it('reserves atomically and reconciles actual provider usage', async () => {
|
|
113
|
+
const first = context();
|
|
114
|
+
await prepareLlmBilling(first, resolved);
|
|
115
|
+
|
|
116
|
+
const competing = context();
|
|
117
|
+
await expect(prepareLlmBilling(competing, resolved)).rejects.toMatchObject<Partial<AiApiQuotaError>>({
|
|
118
|
+
code: 'request_quota_exceeded',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
markLlmProviderAttempted(first);
|
|
122
|
+
const finalized = await finalizeLlmBilling(
|
|
123
|
+
first,
|
|
124
|
+
{ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
125
|
+
true,
|
|
126
|
+
);
|
|
127
|
+
expect(finalized).toMatchObject({ estimatedCost: '0.00012500', costStatus: 'calculated' });
|
|
128
|
+
|
|
129
|
+
const bucket = await db.getRepository('aiApiUserQuotaBuckets').findOne();
|
|
130
|
+
expect(String(bucket?.get('requestCount'))).toBe('1');
|
|
131
|
+
expect(String(bucket?.get('totalTokens'))).toBe('15');
|
|
132
|
+
expect(String(bucket?.get('reservedRequests'))).toBe('0');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateLlmCost, type PriceSnapshot } from '../billing';
|
|
3
|
+
|
|
4
|
+
function price(values: Partial<PriceSnapshot> = {}): PriceSnapshot {
|
|
5
|
+
return {
|
|
6
|
+
id: 1,
|
|
7
|
+
currency: 'USD',
|
|
8
|
+
inputPricePerMillionTokens: '5.0000000000',
|
|
9
|
+
outputPricePerMillionTokens: '15.0000000000',
|
|
10
|
+
fixedCostPerRequest: '0.0000000000',
|
|
11
|
+
...values,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('AI API LLM cost calculation', () => {
|
|
16
|
+
it('calculates input and output token cost using decimal arithmetic', () => {
|
|
17
|
+
expect(calculateLlmCost(10_000, 2_000, price())).toBe('0.08000000');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('includes a fixed per-request cost', () => {
|
|
21
|
+
expect(calculateLlmCost(0, 0, price({ fixedCostPerRequest: '0.1250000000' }))).toBe('0.12500000');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rounds to eight decimal places without floating-point drift', () => {
|
|
25
|
+
expect(
|
|
26
|
+
calculateLlmCost(
|
|
27
|
+
1,
|
|
28
|
+
1,
|
|
29
|
+
price({ inputPricePerMillionTokens: '0.1000000000', outputPricePerMillionTokens: '0.2000000000' }),
|
|
30
|
+
),
|
|
31
|
+
).toBe('0.00000030');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Context } from '@nocobase/actions';
|
|
3
|
+
import { Op } from 'sequelize';
|
|
4
|
+
import aiApiUsageMonitorResource from '../resource/ai-api-usage-monitor';
|
|
5
|
+
|
|
6
|
+
describe('AI API usage monitor summary', () => {
|
|
7
|
+
it('aggregates filtered token and cost totals', async () => {
|
|
8
|
+
const findOne = vi.fn().mockResolvedValue({
|
|
9
|
+
requestCount: '3',
|
|
10
|
+
inputTokens: '100',
|
|
11
|
+
outputTokens: '25',
|
|
12
|
+
totalTokens: '125',
|
|
13
|
+
});
|
|
14
|
+
const findAll = vi.fn().mockResolvedValue([
|
|
15
|
+
{ currency: 'USD', totalCost: '0.12500000' },
|
|
16
|
+
{ currency: 'EUR', totalCost: '0.05000000' },
|
|
17
|
+
]);
|
|
18
|
+
const context = {
|
|
19
|
+
action: {
|
|
20
|
+
params: {
|
|
21
|
+
start: '2026-08-01T00:00:00.000Z',
|
|
22
|
+
end: '2026-08-01T23:59:59.999Z',
|
|
23
|
+
userId: 7,
|
|
24
|
+
resolvedService: 'custom-llm',
|
|
25
|
+
resolvedModel: 'model-a',
|
|
26
|
+
status: 'succeeded',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
db: {
|
|
30
|
+
getCollection: () => ({ model: { findOne, findAll } }),
|
|
31
|
+
},
|
|
32
|
+
body: undefined,
|
|
33
|
+
} as unknown as Context;
|
|
34
|
+
const next = vi.fn();
|
|
35
|
+
const summary = aiApiUsageMonitorResource.actions?.summary;
|
|
36
|
+
if (typeof summary !== 'function') throw new Error('summary action is not registered');
|
|
37
|
+
|
|
38
|
+
await summary(context, next);
|
|
39
|
+
|
|
40
|
+
const totalsQuery = findOne.mock.calls[0][0];
|
|
41
|
+
expect(totalsQuery.where).toEqual(
|
|
42
|
+
expect.objectContaining({
|
|
43
|
+
userId: 7,
|
|
44
|
+
resolvedService: 'custom-llm',
|
|
45
|
+
resolvedModel: 'model-a',
|
|
46
|
+
status: 'succeeded',
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(totalsQuery.where.startedAt[Op.gte]).toEqual(new Date('2026-08-01T00:00:00.000Z'));
|
|
50
|
+
expect(totalsQuery.where.startedAt[Op.lte]).toEqual(new Date('2026-08-01T23:59:59.999Z'));
|
|
51
|
+
expect(context.body).toEqual({
|
|
52
|
+
requestCount: 3,
|
|
53
|
+
inputTokens: 100,
|
|
54
|
+
outputTokens: 25,
|
|
55
|
+
totalTokens: 125,
|
|
56
|
+
costsByCurrency: [
|
|
57
|
+
{ currency: 'USD', totalCost: '0.12500000' },
|
|
58
|
+
{ currency: 'EUR', totalCost: '0.05000000' },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
expect(next).toHaveBeenCalledOnce();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -79,6 +79,10 @@ describe('AI API chat usage collection', () => {
|
|
|
79
79
|
source: 'unavailable',
|
|
80
80
|
gatewayResponseId: expect.stringMatching(/^chatcmpl-/),
|
|
81
81
|
});
|
|
82
|
+
expect(ctx.state.aiApiLlmBilling).toMatchObject({
|
|
83
|
+
resolution: { service: 'test-service', provider: 'test-provider', model: 'test-model' },
|
|
84
|
+
providerAttempted: true,
|
|
85
|
+
});
|
|
82
86
|
});
|
|
83
87
|
|
|
84
88
|
it('stores provider usage and provider request ID separately from the gateway response ID', async () => {
|
|
@@ -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
|
+
}
|