147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { CostTracker } from '@waggle/agent';
|
|
import {
|
|
bindModelSpendBudget,
|
|
createModelSpendMeter,
|
|
} from '../../src/local/model-spend-meter.js';
|
|
|
|
const TARGET = 'http://127.0.0.1:3333/v1';
|
|
|
|
describe('ModelSpendMeter durable handoff ownership', () => {
|
|
it('forces one root spend scope onto child calls and verifies local billing', async () => {
|
|
const shared = new CostTracker();
|
|
const calls: Array<Record<string, unknown>> = [];
|
|
const runner = bindModelSpendBudget(
|
|
async (config) => {
|
|
calls.push(config as unknown as Record<string, unknown>);
|
|
return { content: 'ok', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 } };
|
|
},
|
|
shared,
|
|
'workspace-a',
|
|
async () => ['ollama/qwen2.5:0.5b'],
|
|
() => 77,
|
|
);
|
|
const baseConfig = {
|
|
litellmUrl: 'http://llm.test',
|
|
litellmApiKey: 'test-key',
|
|
systemPrompt: 'test',
|
|
tools: [],
|
|
messages: [{ role: 'user' as const, content: 'test' }],
|
|
};
|
|
|
|
await runner({
|
|
...baseConfig,
|
|
model: 'openai/gpt-5.3-codex',
|
|
modelSpendTraceId: 999,
|
|
});
|
|
await runner({ ...baseConfig, model: 'ollama/qwen2.5:0.5b' });
|
|
await runner({ ...baseConfig, model: 'ollama/unverified:latest' });
|
|
|
|
expect(calls).toHaveLength(3);
|
|
for (const call of calls) {
|
|
expect(call.modelSpendBudget).toBe(shared);
|
|
expect(call.modelSpendTraceId).toBe(77);
|
|
expect(call.spendWorkspaceId).toBe('workspace-a');
|
|
}
|
|
expect(calls.map((call) => call.modelSpendBillingClass)).toEqual([
|
|
'priced',
|
|
'free',
|
|
'priced',
|
|
]);
|
|
});
|
|
|
|
it('updates live usage without writing a second durable charge', () => {
|
|
const shared = new CostTracker();
|
|
const onCostSettled = vi.fn();
|
|
const meter = createModelSpendMeter(shared, onCostSettled);
|
|
shared.registerModelSpendReservationTarget(TARGET);
|
|
const reservation = meter.reserveModelSpend({
|
|
model: 'claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
maxOutputTokens: 100,
|
|
billingClass: 'priced',
|
|
});
|
|
const binding = JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] });
|
|
const handoff = meter.issueModelSpendReservationHandoff!(reservation, binding, TARGET, 17);
|
|
|
|
expect(handoff).toBeDefined();
|
|
expect(shared.claimModelSpendReservationHandoff(handoff!.token, binding)).toMatchObject({
|
|
reservation,
|
|
durableTraceId: 17,
|
|
estimatedCostUsd: expect.any(Number),
|
|
});
|
|
shared.setModelSpendReservationHandoffDisposition(handoff!.token, 'commit');
|
|
expect(meter.takeModelSpendReservationHandoffDisposition!(handoff!.token)).toBe('commit');
|
|
meter.discardModelSpendReservationHandoff!(handoff!.token);
|
|
|
|
expect(meter.reconcileModelSpend(reservation, { inputTokens: 50, outputTokens: 25 })).toBe(true);
|
|
expect(shared.getDailyTotal()).toBeGreaterThan(0);
|
|
expect(meter.totalCostUsd()).toBe(0);
|
|
expect(onCostSettled).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps legacy persistence for a request without a claimed durable handoff', () => {
|
|
const shared = new CostTracker();
|
|
const onCostSettled = vi.fn();
|
|
const meter = createModelSpendMeter(shared, onCostSettled);
|
|
const reservation = meter.reserveModelSpend({
|
|
model: 'claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
maxOutputTokens: 100,
|
|
billingClass: 'priced',
|
|
});
|
|
|
|
expect(meter.reconcileModelSpend(reservation, { inputTokens: 50, outputTokens: 25 })).toBe(true);
|
|
expect(meter.totalCostUsd()).toBeGreaterThan(0);
|
|
expect(onCostSettled).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('keeps legacy persistence when a commit disposition has no durable trace proof', () => {
|
|
const shared = new CostTracker();
|
|
const onCostSettled = vi.fn();
|
|
const meter = createModelSpendMeter(shared, onCostSettled);
|
|
shared.registerModelSpendReservationTarget(TARGET);
|
|
const reservation = meter.reserveModelSpend({
|
|
model: 'claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
maxOutputTokens: 100,
|
|
billingClass: 'priced',
|
|
});
|
|
const binding = JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] });
|
|
const handoff = meter.issueModelSpendReservationHandoff!(reservation, binding, TARGET);
|
|
shared.claimModelSpendReservationHandoff(handoff!.token, binding);
|
|
shared.setModelSpendReservationHandoffDisposition(handoff!.token, 'commit');
|
|
|
|
expect(meter.takeModelSpendReservationHandoffDisposition!(handoff!.token)).toBe('commit');
|
|
meter.discardModelSpendReservationHandoff!(handoff!.token);
|
|
expect(meter.reconcileModelSpend(reservation, { inputTokens: 50, outputTokens: 25 })).toBe(true);
|
|
expect(meter.totalCostUsd()).toBeGreaterThan(0);
|
|
expect(onCostSettled).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('releases both live ownership and meter state after a definite rejection', () => {
|
|
const shared = new CostTracker();
|
|
const onCostSettled = vi.fn();
|
|
const meter = createModelSpendMeter(shared, onCostSettled);
|
|
shared.registerModelSpendReservationTarget(TARGET);
|
|
const reservation = meter.reserveModelSpend({
|
|
model: 'claude-sonnet-4-6',
|
|
inputTokens: 100,
|
|
maxOutputTokens: 100,
|
|
billingClass: 'priced',
|
|
});
|
|
const binding = JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] });
|
|
const handoff = meter.issueModelSpendReservationHandoff!(reservation, binding, TARGET, 19);
|
|
shared.claimModelSpendReservationHandoff(handoff!.token, binding);
|
|
shared.setModelSpendReservationHandoffDisposition(handoff!.token, 'release');
|
|
|
|
expect(meter.takeModelSpendReservationHandoffDisposition!(handoff!.token)).toBe('release');
|
|
meter.discardModelSpendReservationHandoff!(handoff!.token);
|
|
expect(meter.releaseReservedModelSpend(reservation)).toBe(true);
|
|
expect(shared.getDailyTotal()).toBe(0);
|
|
expect(shared.getReservedDailyTotal()).toBe(0);
|
|
expect(meter.totalCostUsd()).toBe(0);
|
|
expect(onCostSettled).not.toHaveBeenCalled();
|
|
});
|
|
});
|