mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Co-authored-by: caterpillarC15 <caterpillarC15@users.noreply.github.com>
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
import {
|
|
reserve,
|
|
settle,
|
|
sweepExpiredReservations,
|
|
getClientDailyCapCents,
|
|
clientLockKey,
|
|
BudgetExceededError,
|
|
RESERVATION_TTL_MS,
|
|
} from '../../src/core/minions/budget-meter.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
async function seedClient(clientId: string, capUsd: number | null): Promise<void> {
|
|
await engine.executeRaw(
|
|
`INSERT INTO oauth_clients
|
|
(client_id, client_name, client_secret_hash, scope, grant_types, redirect_uris, token_endpoint_auth_method, budget_usd_per_day, created_at, deleted_at)
|
|
VALUES ($1, $1, '', 'agent', ARRAY['client_credentials'], ARRAY[]::text[], 'client_secret_post', $2, now(), NULL)
|
|
ON CONFLICT (client_id) DO UPDATE SET budget_usd_per_day = EXCLUDED.budget_usd_per_day`,
|
|
[clientId, capUsd],
|
|
);
|
|
}
|
|
|
|
describe('minions/budget-meter (v0.38 Slice 2 — D3 reserve-then-settle)', () => {
|
|
describe('clientLockKey (FNV-1a determinism)', () => {
|
|
it('returns the same int for the same client_id', () => {
|
|
expect(clientLockKey('client-a')).toBe(clientLockKey('client-a'));
|
|
});
|
|
it('returns different ints for different client_ids', () => {
|
|
expect(clientLockKey('client-a')).not.toBe(clientLockKey('client-b'));
|
|
});
|
|
it('output fits in positive INT32', () => {
|
|
const k = clientLockKey('any-client-id-with-some-length');
|
|
expect(k).toBeGreaterThanOrEqual(0);
|
|
expect(k).toBeLessThan(2 ** 32);
|
|
});
|
|
});
|
|
|
|
describe('reserve()', () => {
|
|
it('passes when projected total ≤ cap', async () => {
|
|
await seedClient('alice', 5.00);
|
|
const r = await reserve(engine, {
|
|
clientId: 'alice',
|
|
estimatedCents: 100,
|
|
capCents: 500,
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
provider: 'anthropic',
|
|
});
|
|
expect(r.reservationId).toMatch(/^[0-9a-f-]+$/i);
|
|
expect(r.estimatedCents).toBe(100);
|
|
expect(r.ttlMs).toBe(RESERVATION_TTL_MS);
|
|
});
|
|
|
|
it('refuses with BudgetExceededError when projected > cap', async () => {
|
|
await seedClient('alice', 1.00);
|
|
await expect(
|
|
reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 200, capCents: 100,
|
|
model: 'm', provider: 'p',
|
|
}),
|
|
).rejects.toThrow(BudgetExceededError);
|
|
});
|
|
|
|
it('two sequential reserves both succeed when under cap', async () => {
|
|
await seedClient('alice', 5.00);
|
|
const r1 = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 100, capCents: 500,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
const r2 = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 100, capCents: 500,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
expect(r1.reservationId).not.toBe(r2.reservationId);
|
|
});
|
|
|
|
it('refuses second reserve when pending sum pushes over cap', async () => {
|
|
await seedClient('alice', 1.00);
|
|
await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 80, capCents: 100,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
await expect(
|
|
reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 80, capCents: 100,
|
|
model: 'm', provider: 'p',
|
|
}),
|
|
).rejects.toThrow(BudgetExceededError);
|
|
});
|
|
|
|
it('admits only cap-fitting reservations under concurrent pressure', async () => {
|
|
const attempts = await Promise.allSettled(
|
|
Array.from({ length: 10 }, () => reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 20, capCents: 100,
|
|
model: 'm', provider: 'p',
|
|
})),
|
|
);
|
|
const fulfilled = attempts.filter(r => r.status === 'fulfilled');
|
|
const rejected = attempts.filter(r => r.status === 'rejected');
|
|
expect(fulfilled).toHaveLength(5);
|
|
expect(rejected).toHaveLength(5);
|
|
for (const result of rejected) {
|
|
expect((result as PromiseRejectedResult).reason).toBeInstanceOf(BudgetExceededError);
|
|
}
|
|
|
|
const rows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT COALESCE(SUM(estimated_cents), 0)::text AS total
|
|
FROM mcp_spend_reservations
|
|
WHERE client_id = 'alice' AND status = 'pending'`,
|
|
);
|
|
expect(Number(rows[0]?.total)).toBe(100);
|
|
});
|
|
|
|
it('rejects invalid numeric input before opening a transaction', async () => {
|
|
await expect(reserve(engine, {
|
|
clientId: 'alice', estimatedCents: Number.NaN, capCents: 100,
|
|
model: 'm', provider: 'p',
|
|
})).rejects.toThrow(TypeError);
|
|
});
|
|
});
|
|
|
|
describe('settle()', () => {
|
|
it('marks settled and writes to mcp_spend_log', async () => {
|
|
await seedClient('alice', 5.00);
|
|
const r = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 100, capCents: 500,
|
|
model: 'anthropic:sonnet', provider: 'anthropic',
|
|
});
|
|
await settle(engine, r.reservationId, 75);
|
|
const reservationRows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT status, actual_cents::text AS a FROM mcp_spend_reservations WHERE reservation_id = $1`,
|
|
[r.reservationId],
|
|
);
|
|
expect(reservationRows[0]?.status).toBe('settled');
|
|
expect(parseFloat(String(reservationRows[0]?.a))).toBe(75);
|
|
const logRows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT spend_cents::text AS s FROM mcp_spend_log WHERE client_id = $1`,
|
|
['alice'],
|
|
);
|
|
expect(logRows.length).toBe(1);
|
|
expect(parseFloat(String(logRows[0]?.s))).toBe(75);
|
|
});
|
|
|
|
it('second settle on same reservation is no-op', async () => {
|
|
await seedClient('alice', 5.00);
|
|
const r = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 100, capCents: 500,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
await settle(engine, r.reservationId, 50);
|
|
await settle(engine, r.reservationId, 99);
|
|
const rows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT actual_cents::text AS a FROM mcp_spend_reservations WHERE reservation_id = $1`,
|
|
[r.reservationId],
|
|
);
|
|
expect(parseFloat(String(rows[0]?.a))).toBe(50);
|
|
const logCount = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT count(*)::int AS n FROM mcp_spend_log WHERE client_id = $1`,
|
|
['alice'],
|
|
);
|
|
expect(Number(logCount[0]?.n)).toBe(1);
|
|
});
|
|
|
|
it('rolls reservation state back when the spend-log insert fails', async () => {
|
|
const r = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 100, capCents: 500,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
|
|
const failingEngine = Object.create(engine) as PGLiteEngine;
|
|
Object.defineProperty(failingEngine, 'transaction', {
|
|
value: <T>(fn: (tx: PGLiteEngine) => Promise<T>) => engine.transaction(async tx => {
|
|
const failingTx = Object.create(tx) as PGLiteEngine;
|
|
Object.defineProperty(failingTx, 'executeRaw', {
|
|
value: async (query: string, params?: unknown[]) => {
|
|
if (/INSERT\s+INTO\s+mcp_spend_log/i.test(query)) {
|
|
throw new Error('injected spend-log write failure');
|
|
}
|
|
return tx.executeRaw(query, params);
|
|
},
|
|
});
|
|
return fn(failingTx);
|
|
}),
|
|
});
|
|
|
|
await expect(settle(failingEngine, r.reservationId, 75))
|
|
.rejects.toThrow('injected spend-log write failure');
|
|
const rows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT status, actual_cents FROM mcp_spend_reservations WHERE reservation_id = $1`,
|
|
[r.reservationId],
|
|
);
|
|
expect(rows[0]?.status).toBe('pending');
|
|
expect(rows[0]?.actual_cents).toBeNull();
|
|
});
|
|
|
|
it('preserves OAuth token attribution in the committed spend row', async () => {
|
|
const r = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 100, capCents: 500,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
await settle(engine, r.reservationId, 50, 'search_by_image', 'client-token');
|
|
const rows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT token_name FROM mcp_spend_log WHERE client_id = 'alice'`,
|
|
);
|
|
expect(rows[0]?.token_name).toBe('client-token');
|
|
});
|
|
|
|
it('records a paid result that arrives after the reservation expired', async () => {
|
|
const r = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 100, capCents: 500,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
await engine.executeRaw(
|
|
`UPDATE mcp_spend_reservations
|
|
SET status = 'expired', actual_cents = 0
|
|
WHERE reservation_id = $1`,
|
|
[r.reservationId],
|
|
);
|
|
|
|
await settle(engine, r.reservationId, 75);
|
|
const rows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT status, actual_cents::text AS actual
|
|
FROM mcp_spend_reservations
|
|
WHERE reservation_id = $1`,
|
|
[r.reservationId],
|
|
);
|
|
expect(rows[0]?.status).toBe('settled');
|
|
expect(Number(rows[0]?.actual)).toBe(75);
|
|
const logs = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT COALESCE(SUM(spend_cents), 0)::text AS total
|
|
FROM mcp_spend_log
|
|
WHERE client_id = 'alice'`,
|
|
);
|
|
expect(Number(logs[0]?.total)).toBe(75);
|
|
});
|
|
|
|
it('fails closed for an unknown reservation id', async () => {
|
|
await expect(settle(engine, '00000000-0000-0000-0000-000000000099', 1))
|
|
.rejects.toThrow('spend reservation not found');
|
|
});
|
|
});
|
|
|
|
describe('sweepExpiredReservations()', () => {
|
|
it('marks past-TTL pending rows as expired', async () => {
|
|
await seedClient('alice', 5.00);
|
|
const expired = new Date(Date.now() - 60_000).toISOString();
|
|
await engine.executeRaw(
|
|
`INSERT INTO mcp_spend_reservations
|
|
(reservation_id, client_id, estimated_cents, model, provider, status, expires_at)
|
|
VALUES ('00000000-0000-0000-0000-000000000001', 'alice', 50, 'm', 'p', 'pending', $1)`,
|
|
[expired],
|
|
);
|
|
const n = await sweepExpiredReservations(engine);
|
|
expect(n).toBe(1);
|
|
const rows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT status FROM mcp_spend_reservations WHERE reservation_id = '00000000-0000-0000-0000-000000000001'`,
|
|
);
|
|
expect(rows[0]?.status).toBe('expired');
|
|
});
|
|
|
|
it('leaves fresh pending rows alone', async () => {
|
|
await seedClient('alice', 5.00);
|
|
const r = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 50, capCents: 500,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
const n = await sweepExpiredReservations(engine);
|
|
expect(n).toBe(0);
|
|
const rows = await engine.executeRaw<Record<string, unknown>>(
|
|
`SELECT status FROM mcp_spend_reservations WHERE reservation_id = $1`,
|
|
[r.reservationId],
|
|
);
|
|
expect(rows[0]?.status).toBe('pending');
|
|
});
|
|
});
|
|
|
|
describe('getClientDailyCapCents()', () => {
|
|
it('returns cap in cents when set', async () => {
|
|
await seedClient('alice', 5.00);
|
|
expect(await getClientDailyCapCents(engine, 'alice')).toBe(500);
|
|
});
|
|
it('returns null when unset', async () => {
|
|
await seedClient('bob', null);
|
|
expect(await getClientDailyCapCents(engine, 'bob')).toBe(null);
|
|
});
|
|
it('returns null for unknown client', async () => {
|
|
expect(await getClientDailyCapCents(engine, 'nobody')).toBe(null);
|
|
});
|
|
it('fails closed when the accounting read fails', async () => {
|
|
const unavailable = Object.create(engine) as PGLiteEngine;
|
|
Object.defineProperty(unavailable, 'executeRaw', {
|
|
value: async () => { throw new Error('accounting unavailable'); },
|
|
});
|
|
await expect(getClientDailyCapCents(unavailable, 'alice'))
|
|
.rejects.toThrow('accounting unavailable');
|
|
});
|
|
});
|
|
|
|
describe('committed spend feeds next reserve', () => {
|
|
it('settled spend pushes the next reserve over cap', async () => {
|
|
await seedClient('alice', 1.00);
|
|
const r1 = await reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 60, capCents: 100,
|
|
model: 'm', provider: 'p',
|
|
});
|
|
await settle(engine, r1.reservationId, 55);
|
|
await expect(
|
|
reserve(engine, {
|
|
clientId: 'alice', estimatedCents: 50, capCents: 100,
|
|
model: 'm', provider: 'p',
|
|
}),
|
|
).rejects.toThrow(BudgetExceededError);
|
|
});
|
|
});
|
|
});
|