mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
204 lines
8.5 KiB
TypeScript
204 lines
8.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { addUsage, getLifetimeCost, getLifetimeTotal, getSessionContextPercent, getSessionTokens, PendingUsagePool } from "../src/usage.js";
|
|
|
|
// Regression for issue #38 — token semantics + context indicator
|
|
describe("usage", () => {
|
|
describe("getSessionTokens", () => {
|
|
it("uses billed-token semantics (input + output + cacheWrite), not inflated total", () => {
|
|
const session = {
|
|
getSessionStats: () => ({
|
|
tokens: { input: 100, output: 200, cacheRead: 500_000, cacheWrite: 50, total: 500_350 } as any,
|
|
contextUsage: { tokens: 50_300, contextWindow: 200_000, percent: 25 },
|
|
}),
|
|
};
|
|
expect(getSessionTokens(session)).toBe(350);
|
|
});
|
|
|
|
it("returns 0 when session is undefined or stats throw", () => {
|
|
expect(getSessionTokens(undefined)).toBe(0);
|
|
const broken = { getSessionStats: () => { throw new Error("nope"); } } as any;
|
|
expect(getSessionTokens(broken)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("getSessionContextPercent", () => {
|
|
it("returns null when contextUsage is unavailable", () => {
|
|
const session = {
|
|
getSessionStats: () => ({ tokens: { input: 10, output: 20, cacheWrite: 5 } }),
|
|
};
|
|
expect(getSessionContextPercent(session)).toBeNull();
|
|
});
|
|
|
|
it("returns null when percent is null (post-compaction)", () => {
|
|
const session = {
|
|
getSessionStats: () => ({
|
|
tokens: { input: 10, output: 20, cacheWrite: 5 },
|
|
contextUsage: { tokens: null, contextWindow: 200_000, percent: null },
|
|
}),
|
|
};
|
|
expect(getSessionContextPercent(session)).toBeNull();
|
|
});
|
|
|
|
it("returns the upstream percent when available", () => {
|
|
const session = {
|
|
getSessionStats: () => ({
|
|
tokens: { input: 10, output: 20, cacheWrite: 5 },
|
|
contextUsage: { tokens: 50_000, contextWindow: 200_000, percent: 25 },
|
|
}),
|
|
};
|
|
expect(getSessionContextPercent(session)).toBe(25);
|
|
});
|
|
});
|
|
|
|
describe("getLifetimeTotal", () => {
|
|
it("sums components and handles undefined", () => {
|
|
expect(getLifetimeTotal(undefined)).toBe(0);
|
|
expect(getLifetimeTotal({ input: 100, output: 200, cacheWrite: 50 })).toBe(350);
|
|
});
|
|
|
|
// getSessionTokens reads upstream session stats (resets at compaction);
|
|
// getLifetimeTotal reads our independent accumulator (survives compaction).
|
|
// They agree pre-compaction, diverge after — both legitimate signals.
|
|
it("agrees with getSessionTokens pre-compaction, diverges after", () => {
|
|
let sessionStatsTokens = { input: 100, output: 200, cacheWrite: 50 };
|
|
const session = {
|
|
getSessionStats: () => ({ tokens: sessionStatsTokens }),
|
|
};
|
|
const lifetime = { input: 100, output: 200, cacheWrite: 50 };
|
|
|
|
expect(getSessionTokens(session)).toBe(350);
|
|
expect(getLifetimeTotal(lifetime)).toBe(350);
|
|
|
|
// Compaction: upstream replaces session.state.messages, so stats reset.
|
|
// Our accumulator is independent — it keeps growing.
|
|
sessionStatsTokens = { input: 0, output: 0, cacheWrite: 0 };
|
|
|
|
expect(getSessionTokens(session)).toBe(0); // reset
|
|
expect(getLifetimeTotal(lifetime)).toBe(350); // preserved
|
|
|
|
// Subsequent message_end events feed both: session re-fills, accumulator continues
|
|
sessionStatsTokens = { input: 80, output: 150, cacheWrite: 30 };
|
|
lifetime.input += 80; lifetime.output += 150; lifetime.cacheWrite += 30;
|
|
|
|
expect(getSessionTokens(session)).toBe(260); // post-compaction window
|
|
expect(getLifetimeTotal(lifetime)).toBe(610); // 350 + 260, monotone
|
|
});
|
|
|
|
// The accumulator survives compaction because it lives on AgentActivity /
|
|
// AgentRecord, not on session.state.messages (which compaction replaces).
|
|
it("stays monotone across simulated compaction when fed via addUsage-style accumulation", () => {
|
|
const usage = { input: 0, output: 0, cacheWrite: 0 };
|
|
const onUsage = (u: { input: number; output: number; cacheWrite: number }) => {
|
|
usage.input += u.input;
|
|
usage.output += u.output;
|
|
usage.cacheWrite += u.cacheWrite;
|
|
};
|
|
|
|
// 5 normal turns
|
|
for (let i = 0; i < 5; i++) onUsage({ input: 1000, output: 200, cacheWrite: 50 });
|
|
expect(getLifetimeTotal(usage)).toBe(5 * 1250);
|
|
|
|
// Compaction would replace session.state.messages, dropping any sum
|
|
// re-derived from it. Our accumulator is independent — no reset.
|
|
const beforeCompaction = getLifetimeTotal(usage);
|
|
|
|
// 3 more turns post-"compaction"
|
|
for (let i = 0; i < 3; i++) onUsage({ input: 800, output: 150, cacheWrite: 30 });
|
|
expect(getLifetimeTotal(usage)).toBe(beforeCompaction + 3 * 980);
|
|
expect(getLifetimeTotal(usage)).toBeGreaterThan(beforeCompaction); // monotone
|
|
|
|
// input + output + cacheWrite = total — by construction, no drift
|
|
expect(usage.input + usage.output + usage.cacheWrite).toBe(getLifetimeTotal(usage));
|
|
});
|
|
});
|
|
|
|
describe("cost accumulation", () => {
|
|
it("sums cost across messages but keeps it out of the token total", () => {
|
|
const usage = { input: 0, output: 0, cacheWrite: 0 };
|
|
addUsage(usage, { input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.002 });
|
|
addUsage(usage, { input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: 0.004 });
|
|
|
|
expect(getLifetimeCost(usage)).toBeCloseTo(0.006, 10);
|
|
// The load-bearing half: the display total takes neither the money nor
|
|
// the re-read prefix, even though both are accumulated on the same object.
|
|
expect(getLifetimeTotal(usage)).toBe(460);
|
|
expect(usage.cacheRead).toBe(2700);
|
|
});
|
|
|
|
it("leaves cost absent when nothing priced anything", () => {
|
|
// An unpriced model reports 0 per message. Distinguishable from "counted
|
|
// and free" only by the field never being written at all.
|
|
const usage: { input: number; output: number; cacheWrite: number; cost?: number } =
|
|
{ input: 0, output: 0, cacheWrite: 0 };
|
|
addUsage(usage, { input: 10, output: 5, cacheWrite: 0, cost: 0 });
|
|
|
|
expect(usage.cost).toBeUndefined();
|
|
expect(getLifetimeCost(usage)).toBe(0);
|
|
});
|
|
|
|
it("reads a missing cost as 0", () => {
|
|
expect(getLifetimeCost(undefined)).toBe(0);
|
|
expect(getLifetimeCost({ input: 1, output: 1, cacheWrite: 0 })).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("PendingUsagePool", () => {
|
|
it("drains what it accumulated as a complete pi Usage", () => {
|
|
const pool = new PendingUsagePool();
|
|
pool.add({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.01 });
|
|
pool.add({ input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: 0.02 });
|
|
|
|
expect(pool.drain()).toEqual({
|
|
input: 300,
|
|
output: 130,
|
|
// Summed, unlike the display total (#38): pi counts the parent's own
|
|
// messages this way, and the prefix is re-billed on every call.
|
|
cacheRead: 2700,
|
|
cacheWrite: 30,
|
|
totalTokens: 3160,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.03 },
|
|
});
|
|
});
|
|
|
|
it("empties on drain, so no message is reported twice", () => {
|
|
const pool = new PendingUsagePool();
|
|
pool.add({ input: 100, output: 50, cacheWrite: 10, cost: 0.01 });
|
|
|
|
expect(pool.drain()?.totalTokens).toBe(160);
|
|
expect(pool.drain()).toBeUndefined();
|
|
});
|
|
|
|
it("handles an accumulator that never saw a cacheRead or a cost", () => {
|
|
// Both fields are optional and written lazily, so an agent on a provider
|
|
// that reports neither leaves them absent rather than zero.
|
|
const pool = new PendingUsagePool();
|
|
pool.add({ input: 100, output: 50, cacheWrite: 10 });
|
|
|
|
expect(pool.drain()).toEqual({
|
|
input: 100, output: 50, cacheRead: 0, cacheWrite: 10, totalTokens: 160,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
});
|
|
});
|
|
|
|
it("returns undefined when nothing has been added", () => {
|
|
expect(new PendingUsagePool().drain()).toBeUndefined();
|
|
});
|
|
|
|
it("still reports tokens spent by a model with no pricing", () => {
|
|
const pool = new PendingUsagePool();
|
|
pool.add({ input: 100, output: 50, cacheWrite: 10, cost: 0 });
|
|
|
|
const drained = pool.drain();
|
|
expect(drained?.totalTokens).toBe(160);
|
|
expect(drained?.cost.total).toBe(0);
|
|
});
|
|
|
|
it("reports nothing for a message that spent nothing", () => {
|
|
const pool = new PendingUsagePool();
|
|
pool.add({ input: 0, output: 0, cacheWrite: 0, cost: 0 });
|
|
|
|
expect(pool.drain()).toBeUndefined();
|
|
});
|
|
});
|
|
});
|