@wopr-network/platform-core 1.74.0 → 1.75.0
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/fleet/drizzle-bot-instance-repository.d.ts +3 -1
- package/dist/fleet/drizzle-bot-instance-repository.js +8 -2
- package/dist/fleet/instance.d.ts +8 -0
- package/dist/fleet/instance.js +20 -0
- package/dist/fleet/repository-types.d.ts +1 -1
- package/dist/gateway/proxy.js +10 -2
- package/dist/gateway/types.d.ts +3 -1
- package/dist/index.js +4 -0
- package/dist/monetization/credits/bot-billing.test.js +35 -1
- package/dist/monetization/credits/runtime-cron.d.ts +10 -2
- package/dist/monetization/credits/runtime-cron.js +19 -4
- package/dist/monetization/credits/runtime-cron.test.js +49 -34
- package/dist/monetization/credits/storage-tier-billing.test.js +9 -1
- package/dist/monetization/credits/storage-tier-cron.test.js +13 -7
- package/dist/server/__tests__/container.test.js +5 -1
- package/dist/server/container.d.ts +2 -0
- package/dist/server/container.js +6 -1
- package/dist/server/index.js +1 -1
- package/dist/server/mount-routes.d.ts +1 -1
- package/dist/server/mount-routes.js +35 -2
- package/dist/server/routes/__tests__/admin.test.js +3 -3
- package/package.json +1 -1
- package/src/fleet/drizzle-bot-instance-repository.ts +15 -2
- package/src/fleet/instance.ts +21 -0
- package/src/fleet/repository-types.ts +1 -1
- package/src/gateway/proxy.ts +9 -2
- package/src/gateway/types.ts +3 -1
- package/src/index.ts +4 -0
- package/src/monetization/credits/bot-billing.test.ts +35 -1
- package/src/monetization/credits/runtime-cron.test.ts +51 -38
- package/src/monetization/credits/runtime-cron.ts +21 -4
- package/src/monetization/credits/storage-tier-billing.test.ts +9 -1
- package/src/monetization/credits/storage-tier-cron.test.ts +13 -7
- package/src/server/__tests__/container.test.ts +5 -1
- package/src/server/container.ts +9 -1
- package/src/server/index.ts +1 -1
- package/src/server/mount-routes.ts +41 -3
- package/src/server/routes/__tests__/admin.test.ts +3 -3
|
@@ -2,9 +2,11 @@ import { Credit, DrizzleLedger, InsufficientBalanceError } from "@wopr-network/p
|
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { RESOURCE_TIERS } from "../../fleet/resource-tiers.js";
|
|
4
4
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
5
|
-
import { buildResourceTierCosts, DAILY_BOT_COST, runRuntimeDeductions } from "./runtime-cron.js";
|
|
5
|
+
import { buildResourceTierCosts, DAILY_BOT_COST, dailyBotCost, runRuntimeDeductions } from "./runtime-cron.js";
|
|
6
6
|
describe("runRuntimeDeductions", () => {
|
|
7
7
|
const TODAY = "2025-01-01";
|
|
8
|
+
/** Dynamic daily cost for TODAY's month (January = 31 days) */
|
|
9
|
+
const COST = dailyBotCost(TODAY);
|
|
8
10
|
let pool;
|
|
9
11
|
let ledger;
|
|
10
12
|
beforeAll(async () => {
|
|
@@ -20,7 +22,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
20
22
|
await ledger.seedSystemAccounts();
|
|
21
23
|
});
|
|
22
24
|
it("DAILY_BOT_COST equals 17 cents", () => {
|
|
23
|
-
expect(DAILY_BOT_COST.
|
|
25
|
+
expect(DAILY_BOT_COST.toCentsRounded()).toBe(17);
|
|
24
26
|
});
|
|
25
27
|
it("returns empty result when no tenants have balance", async () => {
|
|
26
28
|
const result = await runRuntimeDeductions({
|
|
@@ -51,7 +53,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
51
53
|
});
|
|
52
54
|
expect(result.processed).toBe(1);
|
|
53
55
|
expect(result.suspended).toEqual([]);
|
|
54
|
-
expect((await ledger.balance("tenant-1")).
|
|
56
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(Credit.fromCents(500).subtract(COST.multiply(2)).toCentsRounded());
|
|
55
57
|
});
|
|
56
58
|
it("partial deduction and suspension when balance is insufficient", async () => {
|
|
57
59
|
await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
|
|
@@ -120,7 +122,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
120
122
|
});
|
|
121
123
|
it("catches InsufficientBalanceError from debit and suspends", async () => {
|
|
122
124
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
|
|
123
|
-
vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0),
|
|
125
|
+
vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), COST));
|
|
124
126
|
const onSuspend = vi.fn();
|
|
125
127
|
const result = await runRuntimeDeductions({
|
|
126
128
|
ledger,
|
|
@@ -135,7 +137,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
135
137
|
});
|
|
136
138
|
it("catches InsufficientBalanceError without onSuspend callback", async () => {
|
|
137
139
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
|
|
138
|
-
vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0),
|
|
140
|
+
vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), COST));
|
|
139
141
|
const result = await runRuntimeDeductions({
|
|
140
142
|
ledger,
|
|
141
143
|
date: TODAY,
|
|
@@ -171,7 +173,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
171
173
|
expect(onLowBalance).toHaveBeenCalledOnce();
|
|
172
174
|
const [calledTenant, calledBalance] = onLowBalance.mock.calls[0];
|
|
173
175
|
expect(calledTenant).toBe("tenant-1");
|
|
174
|
-
expect(calledBalance.
|
|
176
|
+
expect(calledBalance.toCentsRounded()).toBe(Credit.fromCents(110).subtract(COST).toCentsRounded());
|
|
175
177
|
});
|
|
176
178
|
it("does NOT fire onLowBalance when balance was already below threshold before deduction", async () => {
|
|
177
179
|
await ledger.credit("tenant-1", Credit.fromCents(90), "purchase", { description: "top-up" });
|
|
@@ -185,7 +187,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
185
187
|
expect(onLowBalance).not.toHaveBeenCalled();
|
|
186
188
|
});
|
|
187
189
|
it("fires onCreditsExhausted when full deduction causes balance to drop to 0", async () => {
|
|
188
|
-
await ledger.credit("tenant-1",
|
|
190
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
189
191
|
const onCreditsExhausted = vi.fn();
|
|
190
192
|
await runRuntimeDeductions({
|
|
191
193
|
ledger,
|
|
@@ -197,8 +199,8 @@ describe("runRuntimeDeductions", () => {
|
|
|
197
199
|
expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
|
|
198
200
|
});
|
|
199
201
|
it("suspends tenant when full deduction causes balance to drop to exactly 0", async () => {
|
|
200
|
-
// Balance = exactly 1 bot *
|
|
201
|
-
await ledger.credit("tenant-1",
|
|
202
|
+
// Balance = exactly 1 bot * dailyBotCost → full deduction → 0
|
|
203
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
202
204
|
const onSuspend = vi.fn();
|
|
203
205
|
const onCreditsExhausted = vi.fn();
|
|
204
206
|
const result = await runRuntimeDeductions({
|
|
@@ -237,7 +239,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
237
239
|
expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
|
|
238
240
|
});
|
|
239
241
|
it("skips resource tier partial debit when balance is exactly 0 after runtime", async () => {
|
|
240
|
-
await ledger.credit("tenant-1",
|
|
242
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
241
243
|
const onCreditsExhausted = vi.fn();
|
|
242
244
|
const result = await runRuntimeDeductions({
|
|
243
245
|
ledger,
|
|
@@ -255,7 +257,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
255
257
|
// triggering the zero-crossing suspend in the runtime block.
|
|
256
258
|
// Storage cost (5 cents) then tries to suspend again via its else-branch (balance 0 < 5).
|
|
257
259
|
// The !result.suspended.includes(tenantId) guard must prevent onSuspend being called twice.
|
|
258
|
-
await ledger.credit("tenant-1",
|
|
260
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
259
261
|
const onSuspend = vi.fn();
|
|
260
262
|
const result = await runRuntimeDeductions({
|
|
261
263
|
ledger,
|
|
@@ -269,9 +271,9 @@ describe("runRuntimeDeductions", () => {
|
|
|
269
271
|
expect(onSuspend).toHaveBeenCalledTimes(1);
|
|
270
272
|
});
|
|
271
273
|
it("buildResourceTierCosts: deducts pro tier surcharge via getResourceTierCosts", async () => {
|
|
272
|
-
const proTierCost = RESOURCE_TIERS.pro.dailyCost
|
|
273
|
-
const
|
|
274
|
-
await ledger.credit("tenant-1",
|
|
274
|
+
const proTierCost = RESOURCE_TIERS.pro.dailyCost;
|
|
275
|
+
const startCredit = COST.add(proTierCost).add(Credit.fromCents(10));
|
|
276
|
+
await ledger.credit("tenant-1", startCredit, "purchase", { description: "top-up" });
|
|
275
277
|
const mockRepo = {
|
|
276
278
|
getResourceTier: async (_botId) => "pro",
|
|
277
279
|
};
|
|
@@ -282,8 +284,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
282
284
|
getActiveBotCount: async () => 1,
|
|
283
285
|
getResourceTierCosts,
|
|
284
286
|
});
|
|
285
|
-
|
|
286
|
-
expect((await ledger.balance("tenant-1")).toCents()).toBe(expected);
|
|
287
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(10);
|
|
287
288
|
});
|
|
288
289
|
it("treats unique constraint violation from concurrent debit as already-billed (skip, not error)", async () => {
|
|
289
290
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
|
|
@@ -300,26 +301,31 @@ describe("runRuntimeDeductions", () => {
|
|
|
300
301
|
});
|
|
301
302
|
it("is idempotent — second run on same date does not double-deduct", async () => {
|
|
302
303
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
|
|
304
|
+
const JUNE_DATE = "2025-06-15";
|
|
305
|
+
const JUNE_COST = dailyBotCost(JUNE_DATE);
|
|
306
|
+
const expectedBalance = Credit.fromCents(500).subtract(JUNE_COST);
|
|
303
307
|
const cfg = {
|
|
304
308
|
ledger,
|
|
305
309
|
getActiveBotCount: async () => 1,
|
|
306
|
-
date:
|
|
310
|
+
date: JUNE_DATE,
|
|
307
311
|
};
|
|
308
312
|
const first = await runRuntimeDeductions(cfg);
|
|
309
313
|
expect(first.processed).toBe(1);
|
|
310
|
-
expect((await ledger.balance("tenant-1")).
|
|
314
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedBalance.toCentsRounded());
|
|
311
315
|
const second = await runRuntimeDeductions(cfg);
|
|
312
316
|
expect(second.processed).toBe(0);
|
|
313
317
|
expect(second.skipped).toContain("tenant-1");
|
|
314
318
|
// Balance unchanged after second run
|
|
315
|
-
expect((await ledger.balance("tenant-1")).
|
|
319
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedBalance.toCentsRounded());
|
|
316
320
|
});
|
|
317
321
|
it("bills surcharges on retry when runtime was already billed (crash recovery)", async () => {
|
|
318
322
|
// Setup: tenant with enough balance for runtime + tier + storage + addon
|
|
319
323
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
324
|
+
const JUL_DATE = "2025-07-01";
|
|
325
|
+
const JUL_COST = dailyBotCost(JUL_DATE);
|
|
320
326
|
const cfg = {
|
|
321
327
|
ledger,
|
|
322
|
-
date:
|
|
328
|
+
date: JUL_DATE,
|
|
323
329
|
getActiveBotCount: async () => 1,
|
|
324
330
|
getResourceTierCosts: async () => Credit.fromCents(10),
|
|
325
331
|
getStorageTierCosts: async () => Credit.fromCents(8),
|
|
@@ -328,23 +334,28 @@ describe("runRuntimeDeductions", () => {
|
|
|
328
334
|
// First run — bills everything
|
|
329
335
|
const first = await runRuntimeDeductions(cfg);
|
|
330
336
|
expect(first.processed).toBe(1);
|
|
331
|
-
|
|
332
|
-
|
|
337
|
+
const expectedAfterFirst = Credit.fromCents(1000)
|
|
338
|
+
.subtract(JUL_COST)
|
|
339
|
+
.subtract(Credit.fromCents(10))
|
|
340
|
+
.subtract(Credit.fromCents(8))
|
|
341
|
+
.subtract(Credit.fromCents(5));
|
|
342
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedAfterFirst.toCentsRounded());
|
|
333
343
|
// Second run — all already billed, should skip
|
|
334
344
|
const second = await runRuntimeDeductions(cfg);
|
|
335
345
|
expect(second.skipped).toContain("tenant-1");
|
|
336
346
|
expect(second.processed).toBe(0);
|
|
337
|
-
expect((await ledger.balance("tenant-1")).
|
|
347
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedAfterFirst.toCentsRounded());
|
|
338
348
|
});
|
|
339
349
|
it("bills remaining surcharges when runtime was billed but surcharges were not (simulated crash)", async () => {
|
|
340
350
|
// Setup: tenant with enough balance
|
|
341
351
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
342
352
|
// Simulate crash: manually debit only the runtime charge (as if the cron crashed after this)
|
|
343
|
-
await ledger.debit("tenant-1",
|
|
344
|
-
description:
|
|
353
|
+
await ledger.debit("tenant-1", COST, "bot_runtime", {
|
|
354
|
+
description: `Daily runtime: 1 bot(s) x $${COST.toDollars().toFixed(4)}`,
|
|
345
355
|
referenceId: `runtime:2025-07-02:tenant-1`,
|
|
346
356
|
});
|
|
347
|
-
|
|
357
|
+
const afterRuntime = Credit.fromCents(1000).subtract(COST);
|
|
358
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(afterRuntime.toCentsRounded());
|
|
348
359
|
// Retry run — runtime already billed, but surcharges should still be billed
|
|
349
360
|
const result = await runRuntimeDeductions({
|
|
350
361
|
ledger,
|
|
@@ -356,21 +367,25 @@ describe("runRuntimeDeductions", () => {
|
|
|
356
367
|
});
|
|
357
368
|
expect(result.processed).toBe(1);
|
|
358
369
|
expect(result.skipped).not.toContain("tenant-1");
|
|
359
|
-
|
|
360
|
-
|
|
370
|
+
const expectedFinal = afterRuntime
|
|
371
|
+
.subtract(Credit.fromCents(10))
|
|
372
|
+
.subtract(Credit.fromCents(8))
|
|
373
|
+
.subtract(Credit.fromCents(5));
|
|
374
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedFinal.toCentsRounded());
|
|
361
375
|
});
|
|
362
376
|
it("bills only missing surcharges when some were already committed (simulated partial crash)", async () => {
|
|
363
377
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
364
378
|
// Simulate: runtime + tier already billed, storage + addon not yet
|
|
365
|
-
await ledger.debit("tenant-1",
|
|
366
|
-
description:
|
|
379
|
+
await ledger.debit("tenant-1", COST, "bot_runtime", {
|
|
380
|
+
description: `Daily runtime: 1 bot(s) x $${COST.toDollars().toFixed(4)}`,
|
|
367
381
|
referenceId: `runtime:2025-07-03:tenant-1`,
|
|
368
382
|
});
|
|
369
383
|
await ledger.debit("tenant-1", Credit.fromCents(10), "resource_upgrade", {
|
|
370
384
|
description: "Daily resource tier surcharge",
|
|
371
385
|
referenceId: `runtime-tier:2025-07-03:tenant-1`,
|
|
372
386
|
});
|
|
373
|
-
|
|
387
|
+
const afterRuntimeAndTier = Credit.fromCents(1000).subtract(COST).subtract(Credit.fromCents(10));
|
|
388
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(afterRuntimeAndTier.toCentsRounded());
|
|
374
389
|
const result = await runRuntimeDeductions({
|
|
375
390
|
ledger,
|
|
376
391
|
date: "2025-07-03",
|
|
@@ -380,8 +395,8 @@ describe("runRuntimeDeductions", () => {
|
|
|
380
395
|
getAddonCosts: async () => Credit.fromCents(5),
|
|
381
396
|
});
|
|
382
397
|
expect(result.processed).toBe(1);
|
|
383
|
-
|
|
384
|
-
expect((await ledger.balance("tenant-1")).
|
|
398
|
+
const expectedFinal = afterRuntimeAndTier.subtract(Credit.fromCents(8)).subtract(Credit.fromCents(5));
|
|
399
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedFinal.toCentsRounded());
|
|
385
400
|
});
|
|
386
401
|
it("does not double-debit runtime on retry after partial deduction + crash", async () => {
|
|
387
402
|
await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
|
|
@@ -405,7 +420,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
405
420
|
it("trial balance remains balanced after crash-recovery billing", async () => {
|
|
406
421
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
407
422
|
// Simulate crash: only runtime billed
|
|
408
|
-
await ledger.debit("tenant-1",
|
|
423
|
+
await ledger.debit("tenant-1", COST, "bot_runtime", {
|
|
409
424
|
description: "Daily runtime: 1 bot(s) x $0.17",
|
|
410
425
|
referenceId: `runtime:2025-07-05:tenant-1`,
|
|
411
426
|
});
|
|
@@ -5,6 +5,7 @@ import { DrizzleBotBilling } from "./bot-billing.js";
|
|
|
5
5
|
describe("bot-billing storage tier", () => {
|
|
6
6
|
let pool;
|
|
7
7
|
let db;
|
|
8
|
+
let repo;
|
|
8
9
|
let billing;
|
|
9
10
|
beforeAll(async () => {
|
|
10
11
|
({ db, pool } = await createTestDb());
|
|
@@ -16,14 +17,17 @@ describe("bot-billing storage tier", () => {
|
|
|
16
17
|
});
|
|
17
18
|
beforeEach(async () => {
|
|
18
19
|
await rollbackTestTransaction(pool);
|
|
19
|
-
|
|
20
|
+
repo = new DrizzleBotInstanceRepository(db);
|
|
21
|
+
billing = new DrizzleBotBilling(repo);
|
|
20
22
|
});
|
|
21
23
|
it("new bot defaults to standard storage tier", async () => {
|
|
22
24
|
await billing.registerBot("bot-1", "tenant-1", "TestBot");
|
|
25
|
+
await repo.startBilling("bot-1");
|
|
23
26
|
expect(await billing.getStorageTier("bot-1")).toBe("standard");
|
|
24
27
|
});
|
|
25
28
|
it("setStorageTier updates tier", async () => {
|
|
26
29
|
await billing.registerBot("bot-1", "tenant-1", "TestBot");
|
|
30
|
+
await repo.startBilling("bot-1");
|
|
27
31
|
await billing.setStorageTier("bot-1", "pro");
|
|
28
32
|
expect(await billing.getStorageTier("bot-1")).toBe("pro");
|
|
29
33
|
});
|
|
@@ -32,8 +36,11 @@ describe("bot-billing storage tier", () => {
|
|
|
32
36
|
});
|
|
33
37
|
it("getStorageTierCostsForTenant sums active bot storage costs", async () => {
|
|
34
38
|
await billing.registerBot("bot-1", "tenant-1", "Bot1");
|
|
39
|
+
await repo.startBilling("bot-1");
|
|
35
40
|
await billing.registerBot("bot-2", "tenant-1", "Bot2");
|
|
41
|
+
await repo.startBilling("bot-2");
|
|
36
42
|
await billing.registerBot("bot-3", "tenant-1", "Bot3");
|
|
43
|
+
await repo.startBilling("bot-3");
|
|
37
44
|
await billing.setStorageTier("bot-1", "plus"); // 3 credits/day
|
|
38
45
|
await billing.setStorageTier("bot-2", "max"); // 15 credits/day
|
|
39
46
|
// bot-3 stays standard // 0 credits/day
|
|
@@ -41,6 +48,7 @@ describe("bot-billing storage tier", () => {
|
|
|
41
48
|
});
|
|
42
49
|
it("getStorageTierCostsForTenant excludes suspended bots", async () => {
|
|
43
50
|
await billing.registerBot("bot-1", "tenant-1", "Bot1");
|
|
51
|
+
await repo.startBilling("bot-1");
|
|
44
52
|
await billing.setStorageTier("bot-1", "pro"); // 8 credits/day
|
|
45
53
|
await billing.suspendBot("bot-1");
|
|
46
54
|
expect((await billing.getStorageTierCostsForTenant("tenant-1")).toCents()).toBe(0);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
4
|
-
import { runRuntimeDeductions } from "./runtime-cron.js";
|
|
4
|
+
import { dailyBotCost, runRuntimeDeductions } from "./runtime-cron.js";
|
|
5
5
|
describe("runtime cron with storage tiers", () => {
|
|
6
6
|
const TODAY = "2025-01-01";
|
|
7
|
+
const BASE_COST_CREDIT = dailyBotCost(TODAY);
|
|
7
8
|
let pool;
|
|
8
9
|
let db;
|
|
9
10
|
let ledger;
|
|
@@ -28,8 +29,9 @@ describe("runtime cron with storage tiers", () => {
|
|
|
28
29
|
});
|
|
29
30
|
expect(result.processed).toBe(1);
|
|
30
31
|
const balance = await ledger.balance("t1");
|
|
31
|
-
// 1000 -
|
|
32
|
-
|
|
32
|
+
// 1000 - dailyBotCost (base) - 8 (pro storage surcharge)
|
|
33
|
+
const expected = Credit.fromCents(1000).subtract(BASE_COST_CREDIT).subtract(Credit.fromCents(8));
|
|
34
|
+
expect(balance.toCents()).toBe(expected.toCents());
|
|
33
35
|
});
|
|
34
36
|
it("debits only base cost for standard storage tier (zero surcharge)", async () => {
|
|
35
37
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
@@ -40,7 +42,8 @@ describe("runtime cron with storage tiers", () => {
|
|
|
40
42
|
getStorageTierCosts: async () => Credit.ZERO,
|
|
41
43
|
});
|
|
42
44
|
expect(result.processed).toBe(1);
|
|
43
|
-
|
|
45
|
+
const expectedStd = Credit.fromCents(1000).subtract(BASE_COST_CREDIT);
|
|
46
|
+
expect((await ledger.balance("t1")).toCents()).toBe(expectedStd.toCents());
|
|
44
47
|
});
|
|
45
48
|
it("skips storage surcharge when callback not provided (backward compat)", async () => {
|
|
46
49
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
@@ -50,10 +53,13 @@ describe("runtime cron with storage tiers", () => {
|
|
|
50
53
|
getActiveBotCount: async () => 1,
|
|
51
54
|
});
|
|
52
55
|
expect(result.processed).toBe(1);
|
|
53
|
-
|
|
56
|
+
const expectedBackcompat = Credit.fromCents(1000).subtract(BASE_COST_CREDIT);
|
|
57
|
+
expect((await ledger.balance("t1")).toCents()).toBe(expectedBackcompat.toCents());
|
|
54
58
|
});
|
|
55
59
|
it("suspends tenant when storage surcharge exhausts remaining balance", async () => {
|
|
56
|
-
|
|
60
|
+
// Seed just enough for base cost + 3 cents, so storage surcharge (8) exceeds remainder
|
|
61
|
+
const seed = BASE_COST_CREDIT.add(Credit.fromCents(3));
|
|
62
|
+
await ledger.credit("t1", seed, "purchase");
|
|
57
63
|
const suspended = [];
|
|
58
64
|
const result = await runRuntimeDeductions({
|
|
59
65
|
ledger,
|
|
@@ -64,7 +70,7 @@ describe("runtime cron with storage tiers", () => {
|
|
|
64
70
|
suspended.push(tenantId);
|
|
65
71
|
},
|
|
66
72
|
});
|
|
67
|
-
//
|
|
73
|
+
// seed - BASE_COST = 3 remaining, then 8 surcharge > 3, so partial debit + suspend
|
|
68
74
|
expect(result.processed).toBe(1);
|
|
69
75
|
expect(result.suspended).toContain("t1");
|
|
70
76
|
expect((await ledger.balance("t1")).toCents()).toBe(0);
|
|
@@ -142,6 +142,8 @@ describe("createTestContainer", () => {
|
|
|
142
142
|
};
|
|
143
143
|
const gateway = {
|
|
144
144
|
serviceKeyRepo: {},
|
|
145
|
+
meter: {},
|
|
146
|
+
budgetChecker: {},
|
|
145
147
|
};
|
|
146
148
|
const hotPool = {
|
|
147
149
|
start: async () => ({ stop: () => { } }),
|
|
@@ -158,7 +160,9 @@ describe("createTestContainer", () => {
|
|
|
158
160
|
expect(c.hotPool).not.toBeNull();
|
|
159
161
|
});
|
|
160
162
|
it("overrides merge without affecting other defaults", () => {
|
|
161
|
-
const c = createTestContainer({
|
|
163
|
+
const c = createTestContainer({
|
|
164
|
+
gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
|
|
165
|
+
});
|
|
162
166
|
// Overridden field
|
|
163
167
|
expect(c.gateway).not.toBeNull();
|
|
164
168
|
// Other feature services remain null
|
|
@@ -44,6 +44,8 @@ export interface StripeServices {
|
|
|
44
44
|
}
|
|
45
45
|
export interface GatewayServices {
|
|
46
46
|
serviceKeyRepo: IServiceKeyRepository;
|
|
47
|
+
meter: import("../metering/emitter.js").MeterEmitter;
|
|
48
|
+
budgetChecker: import("../monetization/budget/budget-checker.js").IBudgetChecker;
|
|
47
49
|
}
|
|
48
50
|
export interface HotPoolServices {
|
|
49
51
|
/** Start the pool manager (replenish loop + cleanup). */
|
package/dist/server/container.js
CHANGED
|
@@ -126,8 +126,13 @@ export async function buildContainer(bootConfig) {
|
|
|
126
126
|
let gateway = null;
|
|
127
127
|
if (bootConfig.features.gateway) {
|
|
128
128
|
const { DrizzleServiceKeyRepository } = await import("../gateway/service-key-repository.js");
|
|
129
|
+
const { MeterEmitter } = await import("../metering/emitter.js");
|
|
130
|
+
const { DrizzleMeterEventRepository } = await import("../metering/meter-event-repository.js");
|
|
131
|
+
const { DrizzleBudgetChecker } = await import("../monetization/budget/budget-checker.js");
|
|
129
132
|
const serviceKeyRepo = new DrizzleServiceKeyRepository(db);
|
|
130
|
-
|
|
133
|
+
const meter = new MeterEmitter(new DrizzleMeterEventRepository(db), { flushIntervalMs: 5_000 });
|
|
134
|
+
const budgetChecker = new DrizzleBudgetChecker(db, { cacheTtlMs: 30_000 });
|
|
135
|
+
gateway = { serviceKeyRepo, meter, budgetChecker };
|
|
131
136
|
}
|
|
132
137
|
// 12. Build the container (hotPool bound after construction)
|
|
133
138
|
const result = {
|
package/dist/server/index.js
CHANGED
|
@@ -37,7 +37,7 @@ export { createTestContainer } from "./test-container.js";
|
|
|
37
37
|
export async function bootPlatformServer(config) {
|
|
38
38
|
const container = await buildContainer(config);
|
|
39
39
|
const app = new Hono();
|
|
40
|
-
mountRoutes(app, container, {
|
|
40
|
+
await mountRoutes(app, container, {
|
|
41
41
|
provisionSecret: config.provisionSecret,
|
|
42
42
|
cryptoServiceKey: config.cryptoServiceKey,
|
|
43
43
|
platformDomain: container.productConfig.product?.domain ?? "localhost",
|
|
@@ -27,4 +27,4 @@ export interface MountConfig {
|
|
|
27
27
|
* 6. Product-specific route plugins
|
|
28
28
|
* 7. Tenant proxy middleware (catch-all — must be last)
|
|
29
29
|
*/
|
|
30
|
-
export declare function mountRoutes(app: Hono, container: PlatformContainer, config: MountConfig, plugins?: RoutePlugin[]): void
|
|
30
|
+
export declare function mountRoutes(app: Hono, container: PlatformContainer, config: MountConfig, plugins?: RoutePlugin[]): Promise<void>;
|
|
@@ -28,7 +28,7 @@ import { createStripeWebhookRoutes } from "./routes/stripe-webhook.js";
|
|
|
28
28
|
* 6. Product-specific route plugins
|
|
29
29
|
* 7. Tenant proxy middleware (catch-all — must be last)
|
|
30
30
|
*/
|
|
31
|
-
export function mountRoutes(app, container, config, plugins = []) {
|
|
31
|
+
export async function mountRoutes(app, container, config, plugins = []) {
|
|
32
32
|
// 1. CORS middleware
|
|
33
33
|
const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains);
|
|
34
34
|
app.use("*", cors({
|
|
@@ -60,7 +60,40 @@ export function mountRoutes(app, container, config, plugins = []) {
|
|
|
60
60
|
maxInstancesPerTenant: fleetConfig?.maxInstances ?? 5,
|
|
61
61
|
}));
|
|
62
62
|
}
|
|
63
|
-
// 6.
|
|
63
|
+
// 6. Metered inference gateway (when gateway is enabled)
|
|
64
|
+
if (container.gateway) {
|
|
65
|
+
// Validate billing config exists in DB — fail hard, no silent defaults
|
|
66
|
+
const billingConfig = container.productConfig.billing;
|
|
67
|
+
const marginConfig = billingConfig?.marginConfig;
|
|
68
|
+
if (!marginConfig?.default) {
|
|
69
|
+
throw new Error("Gateway enabled but product_billing_config.margin_config.default is not set. " +
|
|
70
|
+
"Seed the DB: INSERT INTO product_billing_config (product_id, margin_config) VALUES ('<id>', '{\"default\": 4.0}')");
|
|
71
|
+
}
|
|
72
|
+
// Live margin — reads from productConfig per-request (DB-cached with TTL)
|
|
73
|
+
const initialMargin = marginConfig.default;
|
|
74
|
+
const resolveMargin = () => {
|
|
75
|
+
const cfg = container.productConfig.billing?.marginConfig;
|
|
76
|
+
return cfg?.default ?? initialMargin;
|
|
77
|
+
};
|
|
78
|
+
const gw = container.gateway;
|
|
79
|
+
const { mountGateway } = await import("../gateway/index.js");
|
|
80
|
+
mountGateway(app, {
|
|
81
|
+
meter: gw.meter,
|
|
82
|
+
budgetChecker: gw.budgetChecker,
|
|
83
|
+
creditLedger: container.creditLedger,
|
|
84
|
+
resolveMargin,
|
|
85
|
+
providers: {
|
|
86
|
+
openrouter: process.env.OPENROUTER_API_KEY
|
|
87
|
+
? { apiKey: process.env.OPENROUTER_API_KEY, baseUrl: process.env.OPENROUTER_BASE_URL || undefined }
|
|
88
|
+
: undefined,
|
|
89
|
+
},
|
|
90
|
+
resolveServiceKey: async (key) => {
|
|
91
|
+
const tenant = await gw.serviceKeyRepo.resolve(key);
|
|
92
|
+
return tenant ?? null;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// 7. Product-specific route plugins
|
|
64
97
|
for (const plugin of plugins) {
|
|
65
98
|
app.route(plugin.path, plugin.handler(container));
|
|
66
99
|
}
|
|
@@ -144,6 +144,7 @@ describe("createAdminRouter", () => {
|
|
|
144
144
|
},
|
|
145
145
|
serviceKeyRepo: {},
|
|
146
146
|
},
|
|
147
|
+
gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
|
|
147
148
|
});
|
|
148
149
|
const caller = makeCaller(container);
|
|
149
150
|
const result = await caller.admin.listAllInstances();
|
|
@@ -185,6 +186,7 @@ describe("createAdminRouter", () => {
|
|
|
185
186
|
},
|
|
186
187
|
serviceKeyRepo: {},
|
|
187
188
|
},
|
|
189
|
+
gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
|
|
188
190
|
});
|
|
189
191
|
const caller = makeCaller(container);
|
|
190
192
|
const result = await caller.admin.listAllInstances();
|
|
@@ -229,9 +231,7 @@ describe("createAdminRouter", () => {
|
|
|
229
231
|
};
|
|
230
232
|
const container = createTestContainer({
|
|
231
233
|
pool: mockPool,
|
|
232
|
-
gateway: {
|
|
233
|
-
serviceKeyRepo: {},
|
|
234
|
-
},
|
|
234
|
+
gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
|
|
235
235
|
});
|
|
236
236
|
const caller = makeCaller(container);
|
|
237
237
|
const result = await caller.admin.billingOverview();
|
package/package.json
CHANGED
|
@@ -218,15 +218,28 @@ export class DrizzleBotInstanceRepository implements IBotInstanceRepository {
|
|
|
218
218
|
.where(eq(botInstances.id, botId));
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
async register(
|
|
221
|
+
async register(
|
|
222
|
+
botId: string,
|
|
223
|
+
tenantId: string,
|
|
224
|
+
name: string,
|
|
225
|
+
billingState: BillingState = "inactive",
|
|
226
|
+
): Promise<void> {
|
|
222
227
|
await this.db.insert(botInstances).values({
|
|
223
228
|
id: botId,
|
|
224
229
|
tenantId,
|
|
225
230
|
name,
|
|
226
|
-
billingState
|
|
231
|
+
billingState,
|
|
227
232
|
});
|
|
228
233
|
}
|
|
229
234
|
|
|
235
|
+
async startBilling(botId: string): Promise<void> {
|
|
236
|
+
await this.db.update(botInstances).set({ billingState: "active" }).where(eq(botInstances.id, botId));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async stopBilling(botId: string): Promise<void> {
|
|
240
|
+
await this.db.update(botInstances).set({ billingState: "inactive" }).where(eq(botInstances.id, botId));
|
|
241
|
+
}
|
|
242
|
+
|
|
230
243
|
async getStorageTier(botId: string): Promise<string | null> {
|
|
231
244
|
const row = (
|
|
232
245
|
await this.db
|
package/src/fleet/instance.ts
CHANGED
|
@@ -109,6 +109,27 @@ export class Instance {
|
|
|
109
109
|
this.emit("bot.created");
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Start billing for this instance ($5/month prorated daily).
|
|
114
|
+
* Call after creation for persistent, billable instances (e.g., Paperclip).
|
|
115
|
+
* Ephemeral instances (e.g., Holy Ship) skip this — they bill per-token at the gateway.
|
|
116
|
+
*/
|
|
117
|
+
async startBilling(): Promise<void> {
|
|
118
|
+
if (!this.instanceRepo) {
|
|
119
|
+
logger.warn("startBilling() called but no instanceRepo available", { id: this.id });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await this.instanceRepo.setBillingState(this.id, "active");
|
|
123
|
+
logger.info("Billing started for instance", { id: this.id, name: this.profile.name });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Stop billing for this instance (e.g., on suspend or downgrade). */
|
|
127
|
+
async stopBilling(): Promise<void> {
|
|
128
|
+
if (!this.instanceRepo) return;
|
|
129
|
+
await this.instanceRepo.setBillingState(this.id, "suspended");
|
|
130
|
+
logger.info("Billing stopped for instance", { id: this.id, name: this.profile.name });
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
async start(): Promise<void> {
|
|
113
134
|
this.assertLocal("start()");
|
|
114
135
|
return this.withLock(async () => {
|
|
@@ -78,7 +78,7 @@ export interface SelfHostedNodeRegistration extends NodeRegistration {
|
|
|
78
78
|
// ---------------------------------------------------------------------------
|
|
79
79
|
|
|
80
80
|
/** Billing lifecycle states for a bot instance. */
|
|
81
|
-
export type BillingState = "active" | "suspended" | "destroyed";
|
|
81
|
+
export type BillingState = "active" | "inactive" | "suspended" | "destroyed";
|
|
82
82
|
|
|
83
83
|
/** Plain domain object for a bot instance — mirrors `bot_instances` table. */
|
|
84
84
|
export interface BotInstance {
|
package/src/gateway/proxy.ts
CHANGED
|
@@ -29,7 +29,11 @@ import type { GatewayAuthEnv } from "./service-key-auth.js";
|
|
|
29
29
|
import { proxySSEStream } from "./streaming.js";
|
|
30
30
|
import type { FetchFn, GatewayConfig, ProviderConfig } from "./types.js";
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Fallback only used when resolveMargin is not provided (tests only).
|
|
34
|
+
* Production MUST provide resolveMargin — mountRoutes enforces this.
|
|
35
|
+
*/
|
|
36
|
+
const TEST_ONLY_MARGIN = 1.3;
|
|
33
37
|
|
|
34
38
|
/** Max call duration cap: 4 hours = 240 minutes. */
|
|
35
39
|
const MAX_CALL_DURATION_MINUTES = 240;
|
|
@@ -96,7 +100,10 @@ export function buildProxyDeps(config: GatewayConfig): ProxyDeps {
|
|
|
96
100
|
providers: config.providers,
|
|
97
101
|
defaultModel: config.defaultModel,
|
|
98
102
|
resolveDefaultModel: config.resolveDefaultModel,
|
|
99
|
-
|
|
103
|
+
get defaultMargin() {
|
|
104
|
+
if (config.resolveMargin) return config.resolveMargin();
|
|
105
|
+
return config.defaultMargin ?? TEST_ONLY_MARGIN;
|
|
106
|
+
},
|
|
100
107
|
fetchFn: config.fetchFn ?? fetch,
|
|
101
108
|
arbitrageRouter: config.arbitrageRouter,
|
|
102
109
|
rateLookupFn: config.rateLookupFn,
|
package/src/gateway/types.ts
CHANGED
|
@@ -115,8 +115,10 @@ export interface GatewayConfig {
|
|
|
115
115
|
graceBufferCents?: number;
|
|
116
116
|
/** Upstream provider credentials */
|
|
117
117
|
providers: ProviderConfig;
|
|
118
|
-
/**
|
|
118
|
+
/** Static margin (for tests only). Production should use resolveMargin. */
|
|
119
119
|
defaultMargin?: number;
|
|
120
|
+
/** Live margin resolver — called per-request, reads from DB. Takes priority over defaultMargin. */
|
|
121
|
+
resolveMargin?: () => number;
|
|
120
122
|
/** Optional arbitrage router for multi-provider cost optimization (WOP-463) */
|
|
121
123
|
arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
|
|
122
124
|
/** Injectable fetch for testing */
|