@frockbot/plugin-billing 0.0.0 → 0.3.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/frockbot.json +28 -0
- package/package.json +41 -6
- package/src/backend.ts +57 -0
- package/src/bot.test.ts +178 -0
- package/src/bot.ts +157 -0
- package/src/client/BotSpendLine.vue +49 -0
- package/src/client/UsageSection.vue +191 -0
- package/src/client/format.ts +13 -0
- package/src/client/index.test.ts +92 -0
- package/src/client/index.ts +56 -0
- package/src/client/state.ts +13 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +5 -0
- package/src/manifest.ts +3 -0
- package/src/pricing.test.ts +56 -0
- package/src/pricing.ts +176 -0
- package/src/shared.ts +355 -0
- package/src/store.test.ts +115 -0
- package/src/store.ts +334 -0
- package/src/user.test.ts +84 -0
- package/src/user.ts +145 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/store.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import {
|
|
2
|
+
USAGE_DETAIL_MAX_ROWS_V1,
|
|
3
|
+
USAGE_DETAIL_RETENTION_DAYS_V1,
|
|
4
|
+
USAGE_MONTH_RETENTION_V1,
|
|
5
|
+
type UsageBreakdownV1,
|
|
6
|
+
type UsageEntryV1,
|
|
7
|
+
type UsageReportV1,
|
|
8
|
+
} from "./shared.js";
|
|
9
|
+
|
|
10
|
+
export type UsageSqlValueV1 = ArrayBuffer | string | number | null;
|
|
11
|
+
|
|
12
|
+
export interface UsageSqlCursorV1<Row extends Record<string, UsageSqlValueV1>> {
|
|
13
|
+
toArray(): Row[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UsageSqlV1 {
|
|
17
|
+
exec<Row extends Record<string, UsageSqlValueV1>>(
|
|
18
|
+
query: string,
|
|
19
|
+
// SqlStorage uses `any[]`; retaining it here lets the native object satisfy
|
|
20
|
+
// this deliberately tiny seam.
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
...bindings: any[]
|
|
23
|
+
): UsageSqlCursorV1<Row>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ENTRY_TABLE = "usage_entries";
|
|
27
|
+
const ROLLUP_TABLE = "usage_rollups";
|
|
28
|
+
const TOTAL_TABLE = "usage_lifetime";
|
|
29
|
+
|
|
30
|
+
interface AggregateRow extends Record<string, UsageSqlValueV1> {
|
|
31
|
+
dimension_id: string;
|
|
32
|
+
cost_micros: number;
|
|
33
|
+
input_tokens: number;
|
|
34
|
+
output_tokens: number;
|
|
35
|
+
voice_seconds: number;
|
|
36
|
+
estimated_calls: number;
|
|
37
|
+
unknown_price_calls: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UsageStoreOptionsV1 {
|
|
41
|
+
sql: UsageSqlV1;
|
|
42
|
+
/** Production supplies DurableObjectStorage.transactionSync. */
|
|
43
|
+
transactionSync?<T>(closure: () => T): T;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
detailRetentionDays?: number;
|
|
46
|
+
detailMaxRows?: number;
|
|
47
|
+
monthRetention?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function utcDayV1(at: string): string {
|
|
51
|
+
return at.slice(0, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function utcMonthV1(at: string): string {
|
|
55
|
+
return at.slice(0, 7);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function breakdownV1(row: AggregateRow): UsageBreakdownV1 {
|
|
59
|
+
return {
|
|
60
|
+
id: String(row.dimension_id),
|
|
61
|
+
costMicros: Number(row.cost_micros),
|
|
62
|
+
inputTokens: Number(row.input_tokens),
|
|
63
|
+
outputTokens: Number(row.output_tokens),
|
|
64
|
+
voiceSeconds: Number(row.voice_seconds),
|
|
65
|
+
estimatedCalls: Number(row.estimated_calls),
|
|
66
|
+
unknownPriceCalls: Number(row.unknown_price_calls),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class UsageStoreV1 {
|
|
71
|
+
private readonly sql: UsageSqlV1;
|
|
72
|
+
private readonly transactionSync: <T>(closure: () => T) => T;
|
|
73
|
+
private readonly now: () => number;
|
|
74
|
+
private readonly detailRetentionDays: number;
|
|
75
|
+
private readonly detailMaxRows: number;
|
|
76
|
+
private readonly monthRetention: number;
|
|
77
|
+
private opened = false;
|
|
78
|
+
|
|
79
|
+
constructor(options: UsageStoreOptionsV1) {
|
|
80
|
+
this.sql = options.sql;
|
|
81
|
+
this.transactionSync = options.transactionSync ?? ((closure) => closure());
|
|
82
|
+
this.now = options.now ?? (() => Date.now());
|
|
83
|
+
this.detailRetentionDays =
|
|
84
|
+
options.detailRetentionDays ?? USAGE_DETAIL_RETENTION_DAYS_V1;
|
|
85
|
+
this.detailMaxRows = options.detailMaxRows ?? USAGE_DETAIL_MAX_ROWS_V1;
|
|
86
|
+
this.monthRetention = options.monthRetention ?? USAGE_MONTH_RETENTION_V1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
open(): void {
|
|
90
|
+
if (this.opened) return;
|
|
91
|
+
this.sql.exec(
|
|
92
|
+
`CREATE TABLE IF NOT EXISTS ${ENTRY_TABLE} (` +
|
|
93
|
+
"entry_id TEXT PRIMARY KEY, kind TEXT NOT NULL, bot_id TEXT, run_id TEXT, " +
|
|
94
|
+
"turn_id TEXT, turn INTEGER, request_id TEXT, at TEXT NOT NULL, provider TEXT NOT NULL, " +
|
|
95
|
+
"model TEXT NOT NULL, binding_id TEXT, input_tokens INTEGER NOT NULL, " +
|
|
96
|
+
"output_tokens INTEGER NOT NULL, cached_input_tokens INTEGER NOT NULL, " +
|
|
97
|
+
"reasoning_tokens INTEGER NOT NULL, voice_seconds INTEGER NOT NULL, " +
|
|
98
|
+
"latency_ms INTEGER NOT NULL, estimated INTEGER NOT NULL, unknown_price INTEGER NOT NULL, " +
|
|
99
|
+
"price_table_version TEXT NOT NULL, cost_micros INTEGER NOT NULL)",
|
|
100
|
+
);
|
|
101
|
+
this.sql.exec(
|
|
102
|
+
`CREATE INDEX IF NOT EXISTS ${ENTRY_TABLE}_at ON ${ENTRY_TABLE} (at)`,
|
|
103
|
+
);
|
|
104
|
+
this.sql.exec(
|
|
105
|
+
`CREATE TABLE IF NOT EXISTS ${ROLLUP_TABLE} (` +
|
|
106
|
+
"period_type TEXT NOT NULL, period TEXT NOT NULL, dimension TEXT NOT NULL, " +
|
|
107
|
+
"dimension_id TEXT NOT NULL, cost_micros INTEGER NOT NULL, input_tokens INTEGER NOT NULL, " +
|
|
108
|
+
"output_tokens INTEGER NOT NULL, voice_seconds INTEGER NOT NULL, estimated_calls INTEGER NOT NULL, " +
|
|
109
|
+
"unknown_price_calls INTEGER NOT NULL, " +
|
|
110
|
+
"PRIMARY KEY (period_type, period, dimension, dimension_id))",
|
|
111
|
+
);
|
|
112
|
+
this.sql.exec(
|
|
113
|
+
`CREATE TABLE IF NOT EXISTS ${TOTAL_TABLE} (` +
|
|
114
|
+
"id INTEGER PRIMARY KEY CHECK (id = 1), cost_micros INTEGER NOT NULL)",
|
|
115
|
+
);
|
|
116
|
+
this.opened = true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
record(entries: readonly UsageEntryV1[]): number {
|
|
120
|
+
this.open();
|
|
121
|
+
return this.transactionSync(() => this.recordInCurrentTransaction(entries));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Caller owns a surrounding Durable Object SQL/sync-KV transaction. */
|
|
125
|
+
recordInCurrentTransaction(entries: readonly UsageEntryV1[]): number {
|
|
126
|
+
this.open();
|
|
127
|
+
let inserted = 0;
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const known = this.sql
|
|
130
|
+
.exec<{ n: number }>(
|
|
131
|
+
`SELECT count(*) AS n FROM ${ENTRY_TABLE} WHERE entry_id = ?`,
|
|
132
|
+
entry.entryId,
|
|
133
|
+
)
|
|
134
|
+
.toArray()[0]?.n;
|
|
135
|
+
if (Number(known ?? 0) > 0) continue;
|
|
136
|
+
this.sql.exec(
|
|
137
|
+
`INSERT INTO ${ENTRY_TABLE} (` +
|
|
138
|
+
"entry_id, kind, bot_id, run_id, turn_id, turn, request_id, at, provider, model, binding_id, " +
|
|
139
|
+
"input_tokens, output_tokens, cached_input_tokens, reasoning_tokens, voice_seconds, latency_ms, " +
|
|
140
|
+
"estimated, unknown_price, price_table_version, cost_micros) " +
|
|
141
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
142
|
+
entry.entryId,
|
|
143
|
+
entry.kind,
|
|
144
|
+
entry.botId ?? null,
|
|
145
|
+
entry.runId ?? null,
|
|
146
|
+
entry.turnId ?? null,
|
|
147
|
+
entry.turn ?? null,
|
|
148
|
+
entry.requestId ?? null,
|
|
149
|
+
entry.at,
|
|
150
|
+
entry.provider,
|
|
151
|
+
entry.model,
|
|
152
|
+
entry.bindingId ?? null,
|
|
153
|
+
entry.inputTokens,
|
|
154
|
+
entry.outputTokens,
|
|
155
|
+
entry.cachedInputTokens,
|
|
156
|
+
entry.reasoningTokens,
|
|
157
|
+
entry.voiceSeconds,
|
|
158
|
+
entry.latencyMs,
|
|
159
|
+
entry.estimated ? 1 : 0,
|
|
160
|
+
entry.unknownPrice ? 1 : 0,
|
|
161
|
+
entry.priceTableVersion,
|
|
162
|
+
entry.costMicros,
|
|
163
|
+
);
|
|
164
|
+
for (const [dimension, dimensionId] of [
|
|
165
|
+
["all", "all"],
|
|
166
|
+
...(entry.botId ? [["bot", entry.botId]] : []),
|
|
167
|
+
["model", `${entry.provider}/${entry.model}`],
|
|
168
|
+
] as const) {
|
|
169
|
+
this.addRollup(
|
|
170
|
+
"day",
|
|
171
|
+
utcDayV1(entry.at),
|
|
172
|
+
dimension,
|
|
173
|
+
dimensionId,
|
|
174
|
+
entry,
|
|
175
|
+
);
|
|
176
|
+
this.addRollup(
|
|
177
|
+
"month",
|
|
178
|
+
utcMonthV1(entry.at),
|
|
179
|
+
dimension,
|
|
180
|
+
dimensionId,
|
|
181
|
+
entry,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
this.sql.exec(
|
|
185
|
+
`INSERT INTO ${TOTAL_TABLE} (id, cost_micros) VALUES (1, ?) ` +
|
|
186
|
+
"ON CONFLICT(id) DO UPDATE SET cost_micros = cost_micros + excluded.cost_micros",
|
|
187
|
+
entry.costMicros,
|
|
188
|
+
);
|
|
189
|
+
inserted += 1;
|
|
190
|
+
}
|
|
191
|
+
this.evict();
|
|
192
|
+
return inserted;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private addRollup(
|
|
196
|
+
periodType: "day" | "month",
|
|
197
|
+
period: string,
|
|
198
|
+
dimension: string,
|
|
199
|
+
dimensionId: string,
|
|
200
|
+
entry: UsageEntryV1,
|
|
201
|
+
): void {
|
|
202
|
+
this.sql.exec(
|
|
203
|
+
`INSERT INTO ${ROLLUP_TABLE} (` +
|
|
204
|
+
"period_type, period, dimension, dimension_id, cost_micros, input_tokens, output_tokens, " +
|
|
205
|
+
"voice_seconds, estimated_calls, unknown_price_calls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
|
206
|
+
"ON CONFLICT(period_type, period, dimension, dimension_id) DO UPDATE SET " +
|
|
207
|
+
"cost_micros = cost_micros + excluded.cost_micros, " +
|
|
208
|
+
"input_tokens = input_tokens + excluded.input_tokens, " +
|
|
209
|
+
"output_tokens = output_tokens + excluded.output_tokens, " +
|
|
210
|
+
"voice_seconds = voice_seconds + excluded.voice_seconds, " +
|
|
211
|
+
"estimated_calls = estimated_calls + excluded.estimated_calls, " +
|
|
212
|
+
"unknown_price_calls = unknown_price_calls + excluded.unknown_price_calls",
|
|
213
|
+
periodType,
|
|
214
|
+
period,
|
|
215
|
+
dimension,
|
|
216
|
+
dimensionId,
|
|
217
|
+
entry.costMicros,
|
|
218
|
+
entry.inputTokens,
|
|
219
|
+
entry.outputTokens,
|
|
220
|
+
entry.voiceSeconds,
|
|
221
|
+
entry.estimated ? 1 : 0,
|
|
222
|
+
entry.unknownPrice ? 1 : 0,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private evict(): void {
|
|
227
|
+
const now = this.now();
|
|
228
|
+
const detailHorizon = new Date(
|
|
229
|
+
now - this.detailRetentionDays * 24 * 60 * 60 * 1_000,
|
|
230
|
+
)
|
|
231
|
+
.toISOString()
|
|
232
|
+
.slice(0, 10);
|
|
233
|
+
this.sql.exec(`DELETE FROM ${ENTRY_TABLE} WHERE at < ?`, detailHorizon);
|
|
234
|
+
this.sql.exec(
|
|
235
|
+
`DELETE FROM ${ROLLUP_TABLE} WHERE period_type = 'day' AND period < ?`,
|
|
236
|
+
detailHorizon,
|
|
237
|
+
);
|
|
238
|
+
const excess =
|
|
239
|
+
Number(
|
|
240
|
+
this.sql
|
|
241
|
+
.exec<{ n: number }>(`SELECT count(*) AS n FROM ${ENTRY_TABLE}`)
|
|
242
|
+
.toArray()[0]?.n ?? 0,
|
|
243
|
+
) - this.detailMaxRows;
|
|
244
|
+
if (excess > 0) {
|
|
245
|
+
this.sql.exec(
|
|
246
|
+
`DELETE FROM ${ENTRY_TABLE} WHERE entry_id IN (` +
|
|
247
|
+
`SELECT entry_id FROM ${ENTRY_TABLE} ORDER BY at ASC, entry_id ASC LIMIT ?)`,
|
|
248
|
+
excess,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const cutoff = new Date(now);
|
|
252
|
+
cutoff.setUTCMonth(cutoff.getUTCMonth() - this.monthRetention);
|
|
253
|
+
this.sql.exec(
|
|
254
|
+
`DELETE FROM ${ROLLUP_TABLE} WHERE period_type = 'month' AND period < ?`,
|
|
255
|
+
cutoff.toISOString().slice(0, 7),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
report(at = new Date(this.now())): UsageReportV1 {
|
|
260
|
+
this.open();
|
|
261
|
+
this.evict();
|
|
262
|
+
const month = at.toISOString().slice(0, 7);
|
|
263
|
+
const all = this.sql
|
|
264
|
+
.exec<AggregateRow>(
|
|
265
|
+
`SELECT dimension_id, cost_micros, input_tokens, output_tokens, voice_seconds, ` +
|
|
266
|
+
`estimated_calls, unknown_price_calls FROM ${ROLLUP_TABLE} ` +
|
|
267
|
+
"WHERE period_type = 'month' AND period = ? AND dimension = 'all'",
|
|
268
|
+
month,
|
|
269
|
+
)
|
|
270
|
+
.toArray()[0];
|
|
271
|
+
const empty: AggregateRow = {
|
|
272
|
+
dimension_id: "all",
|
|
273
|
+
cost_micros: 0,
|
|
274
|
+
input_tokens: 0,
|
|
275
|
+
output_tokens: 0,
|
|
276
|
+
voice_seconds: 0,
|
|
277
|
+
estimated_calls: 0,
|
|
278
|
+
unknown_price_calls: 0,
|
|
279
|
+
};
|
|
280
|
+
const total = breakdownV1(all ?? empty);
|
|
281
|
+
const rows = (dimension: "bot" | "model") =>
|
|
282
|
+
this.sql
|
|
283
|
+
.exec<AggregateRow>(
|
|
284
|
+
`SELECT dimension_id, cost_micros, input_tokens, output_tokens, voice_seconds, ` +
|
|
285
|
+
`estimated_calls, unknown_price_calls FROM ${ROLLUP_TABLE} ` +
|
|
286
|
+
"WHERE period_type = 'month' AND period = ? AND dimension = ? " +
|
|
287
|
+
"ORDER BY cost_micros DESC, dimension_id ASC",
|
|
288
|
+
month,
|
|
289
|
+
dimension,
|
|
290
|
+
)
|
|
291
|
+
.toArray()
|
|
292
|
+
.map(breakdownV1);
|
|
293
|
+
const firstDay = new Date(at);
|
|
294
|
+
firstDay.setUTCHours(0, 0, 0, 0);
|
|
295
|
+
firstDay.setUTCDate(firstDay.getUTCDate() - 29);
|
|
296
|
+
const byDay = new Map(
|
|
297
|
+
this.sql
|
|
298
|
+
.exec<{ period: string; cost_micros: number }>(
|
|
299
|
+
`SELECT period, cost_micros FROM ${ROLLUP_TABLE} ` +
|
|
300
|
+
"WHERE period_type = 'day' AND dimension = 'all' AND period >= ? ORDER BY period ASC",
|
|
301
|
+
firstDay.toISOString().slice(0, 10),
|
|
302
|
+
)
|
|
303
|
+
.toArray()
|
|
304
|
+
.map((row) => [String(row.period), Number(row.cost_micros)]),
|
|
305
|
+
);
|
|
306
|
+
const days = Array.from({ length: 30 }, (_, offset) => {
|
|
307
|
+
const day = new Date(firstDay);
|
|
308
|
+
day.setUTCDate(day.getUTCDate() + offset);
|
|
309
|
+
const key = day.toISOString().slice(0, 10);
|
|
310
|
+
return { day: key, costMicros: byDay.get(key) ?? 0 };
|
|
311
|
+
});
|
|
312
|
+
const lifetime = Number(
|
|
313
|
+
this.sql
|
|
314
|
+
.exec<{ cost_micros: number }>(
|
|
315
|
+
`SELECT cost_micros FROM ${TOTAL_TABLE} WHERE id = 1`,
|
|
316
|
+
)
|
|
317
|
+
.toArray()[0]?.cost_micros ?? 0,
|
|
318
|
+
);
|
|
319
|
+
return {
|
|
320
|
+
schemaVersion: 1,
|
|
321
|
+
month,
|
|
322
|
+
currentMonthCostMicros: total.costMicros,
|
|
323
|
+
lifetimeCostMicros: lifetime,
|
|
324
|
+
currentMonthInputTokens: total.inputTokens,
|
|
325
|
+
currentMonthOutputTokens: total.outputTokens,
|
|
326
|
+
currentMonthVoiceSeconds: total.voiceSeconds,
|
|
327
|
+
estimatedCalls: total.estimatedCalls,
|
|
328
|
+
unknownPriceCalls: total.unknownPriceCalls,
|
|
329
|
+
bots: rows("bot"),
|
|
330
|
+
models: rows("model"),
|
|
331
|
+
days,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
package/src/user.test.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { voiceCostMicrosV1 } from "./pricing.js";
|
|
4
|
+
import type { UsageSqlV1 } from "./store.js";
|
|
5
|
+
import { BillingUserBackendContribution } from "./user.js";
|
|
6
|
+
|
|
7
|
+
function sqlV1(database: Database): UsageSqlV1 {
|
|
8
|
+
return {
|
|
9
|
+
exec(query, ...bindings) {
|
|
10
|
+
const statement = database.query(query);
|
|
11
|
+
if (/^\s*(SELECT|WITH|PRAGMA)/i.test(query)) {
|
|
12
|
+
return { toArray: () => statement.all(...bindings) as never[] };
|
|
13
|
+
}
|
|
14
|
+
statement.run(...bindings);
|
|
15
|
+
return { toArray: () => [] };
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("BillingUserBackendContribution", () => {
|
|
21
|
+
test("cumulative voice receipts settle to one report-frequency-invariant total", () => {
|
|
22
|
+
const database = new Database(":memory:");
|
|
23
|
+
const billing = new BillingUserBackendContribution({
|
|
24
|
+
sql: sqlV1(database),
|
|
25
|
+
now: () => Date.parse("2026-09-04T12:00:00.000Z"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
for (const sessionSeconds of [1, 2, 3]) {
|
|
29
|
+
expect(
|
|
30
|
+
billing.recordVoice({
|
|
31
|
+
day: "2026-09-04",
|
|
32
|
+
sessionId: "voice-one",
|
|
33
|
+
sessionSeconds,
|
|
34
|
+
recordedSeconds: 1,
|
|
35
|
+
at: `2026-09-04T12:00:0${sessionSeconds}.000Z`,
|
|
36
|
+
}),
|
|
37
|
+
).toEqual({ recorded: 1 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expect(billing.report()).toMatchObject({
|
|
41
|
+
currentMonthCostMicros: voiceCostMicrosV1(3),
|
|
42
|
+
lifetimeCostMicros: voiceCostMicrosV1(3),
|
|
43
|
+
currentMonthVoiceSeconds: 3,
|
|
44
|
+
});
|
|
45
|
+
database.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("records Gemini Live duration without inventing a duration price", () => {
|
|
49
|
+
const database = new Database(":memory:");
|
|
50
|
+
const billing = new BillingUserBackendContribution({
|
|
51
|
+
sql: sqlV1(database),
|
|
52
|
+
now: () => Date.parse("2026-09-04T12:00:00.000Z"),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
billing.recordVoice({
|
|
57
|
+
day: "2026-09-04",
|
|
58
|
+
sessionId: "assistant-one",
|
|
59
|
+
sessionSeconds: 30,
|
|
60
|
+
recordedSeconds: 30,
|
|
61
|
+
at: "2026-09-04T12:00:30.000Z",
|
|
62
|
+
provider: "google-ai-studio",
|
|
63
|
+
model: "gemini-3.1-flash-live-preview",
|
|
64
|
+
pricing: "unpriced",
|
|
65
|
+
}),
|
|
66
|
+
).toEqual({ recorded: 1 });
|
|
67
|
+
|
|
68
|
+
expect(billing.report()).toMatchObject({
|
|
69
|
+
currentMonthCostMicros: 0,
|
|
70
|
+
lifetimeCostMicros: 0,
|
|
71
|
+
currentMonthVoiceSeconds: 30,
|
|
72
|
+
unknownPriceCalls: 1,
|
|
73
|
+
models: [
|
|
74
|
+
{
|
|
75
|
+
id: "google-ai-studio/gemini-3.1-flash-live-preview",
|
|
76
|
+
voiceSeconds: 30,
|
|
77
|
+
costMicros: 0,
|
|
78
|
+
unknownPriceCalls: 1,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
database.close();
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/user.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineUserBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
2
|
+
import type { Plugin } from "cordis";
|
|
3
|
+
import {
|
|
4
|
+
voiceIncrementCostMicrosV1,
|
|
5
|
+
MODEL_PRICE_TABLE_VERSION_V1,
|
|
6
|
+
} from "./pricing.js";
|
|
7
|
+
import {
|
|
8
|
+
decodeUsageEntryV1,
|
|
9
|
+
USAGE_ENTRY_PAGE_MAX_V1,
|
|
10
|
+
type UsageEntryV1,
|
|
11
|
+
type UsageReportV1,
|
|
12
|
+
} from "./shared.js";
|
|
13
|
+
import { UsageStoreV1, type UsageSqlV1 } from "./store.js";
|
|
14
|
+
|
|
15
|
+
export interface BillingUserBackendHostV1 {
|
|
16
|
+
sql: UsageSqlV1;
|
|
17
|
+
transactionSync?<T>(closure: () => T): T;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface VoiceEntryInputV1 {
|
|
22
|
+
day: string;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
sessionSeconds: number;
|
|
25
|
+
recordedSeconds: number;
|
|
26
|
+
at: string;
|
|
27
|
+
provider?: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
pricing?: "openai-transcription" | "unpriced";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class BillingUserBackendContribution {
|
|
33
|
+
readonly packageId = "billing";
|
|
34
|
+
private readonly store: UsageStoreV1;
|
|
35
|
+
|
|
36
|
+
constructor(host: BillingUserBackendHostV1) {
|
|
37
|
+
this.store = new UsageStoreV1(host);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recordEntries(input: unknown): { recorded: number; quarantined: number } {
|
|
41
|
+
if (!Array.isArray(input) || input.length > USAGE_ENTRY_PAGE_MAX_V1) {
|
|
42
|
+
throw new Error("usage entry page is invalid");
|
|
43
|
+
}
|
|
44
|
+
const entries: UsageEntryV1[] = [];
|
|
45
|
+
let quarantined = 0;
|
|
46
|
+
for (const candidate of input) {
|
|
47
|
+
try {
|
|
48
|
+
entries.push(decodeUsageEntryV1(candidate));
|
|
49
|
+
} catch {
|
|
50
|
+
quarantined += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { recorded: this.store.record(entries), quarantined };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private voiceEntry(input: VoiceEntryInputV1): UsageEntryV1 {
|
|
57
|
+
if (
|
|
58
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(input.day) ||
|
|
59
|
+
!input.sessionId ||
|
|
60
|
+
!Number.isSafeInteger(input.sessionSeconds) ||
|
|
61
|
+
input.sessionSeconds <= 0 ||
|
|
62
|
+
!Number.isSafeInteger(input.recordedSeconds) ||
|
|
63
|
+
input.recordedSeconds <= 0 ||
|
|
64
|
+
input.recordedSeconds > input.sessionSeconds ||
|
|
65
|
+
(input.provider !== undefined &&
|
|
66
|
+
(typeof input.provider !== "string" ||
|
|
67
|
+
input.provider.length === 0 ||
|
|
68
|
+
input.provider.length > 256)) ||
|
|
69
|
+
(input.model !== undefined &&
|
|
70
|
+
(typeof input.model !== "string" ||
|
|
71
|
+
input.model.length === 0 ||
|
|
72
|
+
input.model.length > 256)) ||
|
|
73
|
+
(input.pricing !== undefined &&
|
|
74
|
+
input.pricing !== "openai-transcription" &&
|
|
75
|
+
input.pricing !== "unpriced") ||
|
|
76
|
+
!Number.isFinite(Date.parse(input.at))
|
|
77
|
+
) {
|
|
78
|
+
throw new Error("voice usage is invalid");
|
|
79
|
+
}
|
|
80
|
+
const priced = input.pricing !== "unpriced";
|
|
81
|
+
return {
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
entryId: `voice:${input.day}:${input.sessionId}:${input.sessionSeconds}`,
|
|
84
|
+
kind: "voice",
|
|
85
|
+
at: input.at,
|
|
86
|
+
provider: input.provider ?? "openai",
|
|
87
|
+
model: input.model ?? "gpt-live-transcribe",
|
|
88
|
+
inputTokens: 0,
|
|
89
|
+
outputTokens: 0,
|
|
90
|
+
cachedInputTokens: 0,
|
|
91
|
+
reasoningTokens: 0,
|
|
92
|
+
voiceSeconds: input.recordedSeconds,
|
|
93
|
+
latencyMs: 0,
|
|
94
|
+
estimated: false,
|
|
95
|
+
unknownPrice: !priced,
|
|
96
|
+
priceTableVersion: MODEL_PRICE_TABLE_VERSION_V1,
|
|
97
|
+
costMicros: priced
|
|
98
|
+
? voiceIncrementCostMicrosV1(
|
|
99
|
+
input.sessionSeconds,
|
|
100
|
+
input.recordedSeconds,
|
|
101
|
+
)
|
|
102
|
+
: 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
recordVoice(input: VoiceEntryInputV1): {
|
|
107
|
+
recorded: number;
|
|
108
|
+
} {
|
|
109
|
+
return { recorded: this.store.record([this.voiceEntry(input)]) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
recordVoiceInCurrentTransaction(input: VoiceEntryInputV1): {
|
|
113
|
+
recorded: number;
|
|
114
|
+
} {
|
|
115
|
+
return {
|
|
116
|
+
recorded: this.store.recordInCurrentTransaction([this.voiceEntry(input)]),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
report(): UsageReportV1 {
|
|
121
|
+
return this.store.report();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface BillingUserApplicationHostV1 {
|
|
126
|
+
billing: BillingUserBackendHostV1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const userContribution = defineUserBackendContribution<
|
|
130
|
+
BillingUserApplicationHostV1,
|
|
131
|
+
BillingUserBackendContribution
|
|
132
|
+
>({
|
|
133
|
+
specifier: "@frockbot/plugin-billing/user",
|
|
134
|
+
create: (host, lifecycle) => {
|
|
135
|
+
const contribution = new BillingUserBackendContribution(host.billing);
|
|
136
|
+
return () => lifecycle.mount(contribution);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export function createBillingUserBackendPlugin(
|
|
141
|
+
host: BillingUserBackendHostV1,
|
|
142
|
+
lifecycle: { mount(value: BillingUserBackendContribution): () => void },
|
|
143
|
+
): Plugin {
|
|
144
|
+
return () => lifecycle.mount(new BillingUserBackendContribution(host));
|
|
145
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
12
|
+
"types": ["bun", "vite/client"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "src/**/*.vue"]
|
|
15
|
+
}
|
package/README.md
DELETED