Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions src/tokenmeter/durable/reconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* Union reconciliation of live sessions and durable checkpoints.
*
* For each session ID, merge live/checkpoint monotonically and count exactly
* once. Checkpoint-only sessions remain historical; duplicate live rows count
* once; reappearing sessions update the same row.
* Cost provenance is preserved (reported wins over estimated) rather than
* blind max.
*/

import { resolveCost } from "../math"
import type { ProjectSessionLike, ProjectUsage } from "../types"
import type { CheckpointRow } from "./types"

const num = (v: unknown): number =>
typeof v === "number" && Number.isFinite(v) ? v : 0

function hasUsageRow(r: CheckpointRow): boolean {
return (
r.cost +
r.input +
r.output +
r.reasoning +
r.cacheRead +
r.cacheWrite +
r.context >
0
)
}

function liveToRow(
session: ProjectSessionLike,
alias: string,
): CheckpointRow | null {
const tokens = session.tokens
const input = num(tokens?.input)
const output = num(tokens?.output)
const reasoning = num(tokens?.reasoning)
const cacheRead = num(tokens?.cache?.read)
const cacheWrite = num(tokens?.cache?.write)
const rawCost = num(session.cost)
const resolved = resolveCost({
cost: rawCost,
providerID: (session as unknown as { model?: { providerID?: unknown } })
?.model?.providerID,
modelID: (session as unknown as { model?: { id?: unknown } })?.model?.id,
tokens: { input, output, reasoning, cacheRead, cacheWrite },
})
const context = input + output + reasoning + cacheRead + cacheWrite
if (
resolved.cost +
input +
output +
reasoning +
cacheRead +
cacheWrite +
context ===
0
)
return null
const timeRaw = (
session as unknown as { time?: { updated?: unknown; created?: unknown } }
)?.time
const updatedAt = num(timeRaw?.updated) || num(timeRaw?.created)
return {
sessionID: session.id,
projectID: session.projectID,
projectAlias: alias,
cost: resolved.cost,
costSource: resolved.source,
input,
output,
reasoning,
cacheRead,
cacheWrite,
cache: cacheRead + cacheWrite,
context,
updatedAt,
checkpointAt: 0,
version: 1,
}
}

function mergeCost(
a: Pick<CheckpointRow, "cost" | "costSource">,
b: Pick<CheckpointRow, "cost" | "costSource">,
): Pick<CheckpointRow, "cost" | "costSource"> {
const aRep = a.costSource === "reported" && a.cost !== 0
const bRep = b.costSource === "reported" && b.cost !== 0
if (aRep && bRep) return a.cost >= b.cost ? a : b
if (aRep) return a
if (bRep) return b
return a.cost >= b.cost ? a : b
}

function mergeRows(a: CheckpointRow, b: CheckpointRow): CheckpointRow {
const costMerged = mergeCost(a, b)
const cacheRead = Math.max(a.cacheRead, b.cacheRead)
const cacheWrite = Math.max(a.cacheWrite, b.cacheWrite)
const cache = cacheRead + cacheWrite
const input = Math.max(a.input, b.input)
const output = Math.max(a.output, b.output)
const reasoning = Math.max(a.reasoning, b.reasoning)
const context = input + output + reasoning + cacheRead + cacheWrite
if (cache !== cacheRead + cacheWrite)
throw new Error("cache invariant violated")
return {
sessionID: a.sessionID,
projectID: a.projectID,
projectAlias: b.projectAlias || a.projectAlias,
cost: costMerged.cost,
costSource: costMerged.costSource,
input,
output,
reasoning,
cacheRead,
cacheWrite,
cache,
context,
updatedAt: Math.max(a.updatedAt, b.updatedAt),
checkpointAt: Math.max(a.checkpointAt, b.checkpointAt),
version: 1,
}
}

/**
* Union of live sessions and checkpoints by session identity.
* - checkpoint-only counts once
* - live + checkpoint merge monotonically and count once
* - duplicate live rows count once
*/
export function reconcileProjectUsage(
projectID: string,
liveSessions: ProjectSessionLike[],
checkpoints: Map<string, CheckpointRow>,
alias?: string,
): ProjectUsage {
const liveMap = new Map<string, CheckpointRow>()
const seen = new Set<string>()
const normAlias = alias ?? ""
for (const s of liveSessions) {
if (!s || typeof s.id !== "string" || !s.id) continue
const pid = (s as unknown as { projectID?: unknown })?.projectID
if (pid != null && pid !== "" && pid !== projectID) continue
if (seen.has(s.id)) continue
seen.add(s.id)
const row = liveToRow(s, normAlias)
if (!row) continue
row.projectID = projectID
liveMap.set(s.id, row)
}

const allIds = new Set<string>([...liveMap.keys(), ...checkpoints.keys()])
let cost = 0
let input = 0
let output = 0
let reasoning = 0
let cacheRead = 0
let cacheWrite = 0
let context = 0
let counted = 0

for (const id of allIds) {
const live = liveMap.get(id)
const cp = checkpoints.get(id)
let entry: CheckpointRow | null = null
if (live && cp) entry = mergeRows(cp, live)
else if (live) entry = live
else if (cp) entry = cp
if (!entry || !hasUsageRow(entry)) continue
cost += entry.cost
input += entry.input
output += entry.output
reasoning += entry.reasoning
cacheRead += entry.cacheRead
cacheWrite += entry.cacheWrite
context += entry.context
counted += 1
}

return {
id: projectID,
sessions: counted,
cost,
context,
input,
output,
reasoning,
cacheRead,
cacheWrite,
cache: cacheRead + cacheWrite,
}
}
1 change: 1 addition & 0 deletions test/browser-dialogs-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// biome-ignore-all lint/style/noNonNullAssertion: navigation harness - non-null after guard
// navigation harness - expanded for V2/eligibility/close-guard fixes
import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync } from "node:fs"
Expand Down
164 changes: 164 additions & 0 deletions test/durable-reconcile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, test } from "bun:test"
import { reconcileProjectUsage } from "../src/tokenmeter/durable/reconcile"
import type { CheckpointRow } from "../src/tokenmeter/durable/types"

function row(
id: string,
projectID: string,
overrides: Partial<CheckpointRow> = {},
): CheckpointRow {
const base: CheckpointRow = {
sessionID: id,
projectID,
projectAlias: "/proj/dir",
cost: 0.01,
costSource: "reported",
input: 100,
output: 50,
reasoning: 10,
cacheRead: 5,
cacheWrite: 5,
cache: 10,
context: 170,
updatedAt: 1000,
checkpointAt: 1000,
version: 1,
}
return Object.assign(base, overrides)
}

describe("durable reconcile β€” pure union logic", () => {
test("reconciles empty and single session", () => {
const empty = reconcileProjectUsage("projA", [], new Map())
expect(empty.sessions).toBe(0)
expect(empty.cost).toBe(0)
const sing = reconcileProjectUsage(
"projA",
[
{
id: "s1",
projectID: "projA",
tokens: { input: 10, output: 5 },
cost: 0.01,
} as never,
],
new Map(),
)
expect(sing.sessions).toBe(1)
expect(sing.input).toBe(10)
})

test("duplicate live IDs count once and different project filtered", () => {
const s1 = {
id: "s1",
projectID: "projA",
tokens: { input: 100, output: 50 },
cost: 0.01,
} as never
const dup = reconcileProjectUsage(
"projA",
[
s1,
Object.assign({}, s1),
{
id: "s1",
projectID: "projA",
tokens: { input: 200, output: 100 },
cost: 0.02,
} as never,
],
new Map(),
)
expect(dup.sessions).toBe(1)
expect(dup.input).toBe(100)
const otherProj = reconcileProjectUsage(
"projA",
[
{
id: "s2",
projectID: "projB",
tokens: { input: 100, output: 50 },
} as never,
],
new Map(),
)
expect(otherProj.sessions).toBe(0)
})

test("checkpoint-only and live+checkpoint merge monotonically", () => {
const cp = new Map<string, CheckpointRow>([
["s1", row("s1", "projA", { input: 1000, cost: 0.01 })],
])
const live = [
{
id: "s1",
projectID: "projA",
tokens: {
input: 2000,
output: 700,
reasoning: 100,
cache: { read: 10, write: 20 },
},
cost: 0.02,
} as never,
]
const merged = reconcileProjectUsage("projA", live, cp)
expect(merged.sessions).toBe(1)
expect(merged.input).toBe(2000)
expect(merged.cacheRead).toBe(10)
const onlyCp = reconcileProjectUsage("projA", [], cp)
expect(onlyCp.sessions).toBe(1)
expect(onlyCp.input).toBe(1000)
})

test("cost provenance reported wins and merges correctly", () => {
const cpReported = new Map<string, CheckpointRow>([
["s1", row("s1", "projA", { cost: 0.05, costSource: "reported" })],
])
const liveEstimated = [
{
id: "s1",
projectID: "projA",
tokens: { input: 10, output: 5 },
cost: 0,
model: { providerID: "openai", id: "gpt-4o" },
} as never,
]
const merged1 = reconcileProjectUsage("projA", liveEstimated, cpReported)
expect(merged1.cost).toBe(0.05)
const cpEstimated = new Map<string, CheckpointRow>([
["s1", row("s1", "projA", { cost: 0.02, costSource: "estimated" })],
])
const liveReported = [
{
id: "s1",
projectID: "projA",
tokens: { input: 10, output: 5 },
cost: 0.03,
} as never,
]
const merged2 = reconcileProjectUsage("projA", liveReported, cpEstimated)
expect(merged2.cost).toBe(0.03)
})

test("empty usage rows are ignored", () => {
const emptyRow = row("s1", "projA", {
cost: 0,
input: 0,
output: 0,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
cache: 0,
context: 0,
})
const cp = new Map<string, CheckpointRow>([["s1", emptyRow]])
const usage = reconcileProjectUsage("projA", [], cp)
expect(usage.sessions).toBe(0)
const liveEmpty = [
{ id: "s2", projectID: "projA", tokens: {}, cost: 0 } as never,
]
const usage2 = reconcileProjectUsage("projA", liveEmpty, new Map())
expect(usage2.sessions).toBe(0)
})
})