diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bda2c17..b531f9d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `PORT=$LICH_WORKTREE_PORT pnpm dev` in the setup script (Settings › Project), or in the terminal, and both worktrees come up. The number is the same every time for a given checkout, so a bookmark to it keeps working across restarts. +- **Settings › Providers › Claude can show what a session has cost.** Off by + default, and deliberately so: on a subscription plan the number is noise, so + with the setting off the footer shows nothing at all — no zero, no greyed-out + figure — and lich never even reads the transcript for it. Turned on, the + footer carries the session's spend at API prices beside the context ring, + summed across every turn it has run, including the conversations it cleared + and the sub-agents it spawned. The total survives a restart, and a session + parked with its worktree keeps it when you reopen it. Prices ship with the + binary and refresh themselves from the published price table the first time a + session runs a model that binary never heard of; while a model has no known + price, the readout stays absent rather than quoting a total that is missing a + turn. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 9254be937..d641fdafb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,13 @@ nobody knows it and that the call site never shows. The mechanism and the histor land on the same number and only the dev server started second finds out. Stability is the trade — a probe would move the port every time the previous server still held it. The main checkout is assigned one like any worktree; the range is fixed, not per-project. +- **The cost readout is priced from a table, not from the provider**: no provider publishes an API for what a + turn cost, so `internal/pricing` bills the transcript's token counts against a baked table that refreshes + itself from LiteLLM's published one when it meets an unknown model. Two consequences: a model nobody has + priced yet makes the readout go *absent* (the scan stops at that line — a total missing a turn is worse than + no total), and the accounting is per `(session, transcript)` in `session_costs`, so a conversation forked + inside the PTY — which copies its history into a new transcript — bills that history twice. lich's own + resume continues the same transcript and is unaffected. - **git status is polled** — one shared poller per repository path (`frontend/src/lib/git/git-status-store.ts`); the lich plugin's `session-touched` hook nudges an immediate refresh. An fs watcher is the upgrade path. - **Persistence is hybrid**: UI prefs in the page's localStorage (`lich.*` keys — the reason the listener port is diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx index fc39e7043..80edc7469 100644 --- a/frontend/src/components/FooterBar.tsx +++ b/frontend/src/components/FooterBar.tsx @@ -8,6 +8,8 @@ import type { DockTab } from "@/components/dock/RightDock" import { useActiveSession } from "@/lib/session/use-active-session" import { useSessionCwd } from "@/lib/session/use-session-cwd" import { useSessionUsage } from "@/lib/session/use-session-usage" +import { useCostReadout } from "@/lib/use-cost-readout" +import { formatCost } from "@/lib/session/session-cost" import { displayPath } from "@/lib/paths" import { useGitStatus } from "@/lib/git/use-git-status" import { usePullRequest } from "@/lib/pulls/use-pull-request" @@ -92,8 +94,10 @@ export function FooterBar({ dock, onDock }: FooterBarProps) { // Context-window occupancy of the active session, read off its transcript at // each turn's end (null until the first turn of a Claude session lands). const usage = useSessionUsage(sessionId) - // The footer context readout is opt-out (Settings › Providers). + // The footer context readout is opt-out (Settings › Providers); the cost + // beside it is opt-in, and off for everyone not billed per token. const { showContextUsage } = useSettings() + const showCost = useCostReadout() const status = useGitStatus(path) const pr = usePullRequest(path, status?.branch ?? "", status?.head ?? "") const now = useNow() @@ -109,6 +113,27 @@ export function FooterBar({ dock, onDock }: FooterBarProps) { } } + // What the session has cost, beside the ring. Rendered only when the backend + // sent a number: on a subscription — and for a model no price table knows — + // there is nothing here at all, which is the point of the setting. + const costReadout = + usage && showCost && usage.costUsd !== null ? ( + + }> + {formatCost(usage.costUsd)} + + +
+ Session cost + + API pricing for every turn this session has run, this conversation and the ones it + cleared. + +
+
+
+ ) : null + // The context-window readout — the ring plus percent, with a detailed // tooltip. Null when the user turned it off (Settings › Providers). const contextReadout = @@ -195,8 +220,9 @@ export function FooterBar({ dock, onDock }: FooterBarProps) { {showContextUsage && } + {costReadout} {contextReadout} - {contextReadout && (status?.branch || path) && ( + {(contextReadout || costReadout) && (status?.branch || path) && ( )} {status?.branch && ( diff --git a/frontend/src/components/settings/ProviderBinSettings.tsx b/frontend/src/components/settings/ProviderBinSettings.tsx index 2017721ab..a3ab1493d 100644 --- a/frontend/src/components/settings/ProviderBinSettings.tsx +++ b/frontend/src/components/settings/ProviderBinSettings.tsx @@ -3,6 +3,8 @@ import { Store } from "@/lib/rpc" import { useProjects } from "@/providers/projects" import { binKey } from "@/lib/providers-store" import { useSettings } from "@/providers/settings" +import { setCostReadout } from "@/lib/cost-readout-store" +import { useCostReadout } from "@/lib/use-cost-readout" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { SettingBlock } from "./SettingBlock" @@ -22,6 +24,7 @@ export function ProviderBinSettings({ }) { const { projects } = useProjects() const { showContextUsage, setShowContextUsage } = useSettings() + const showCost = useCostReadout() const project = projects.find((p) => p.id === projectId) const key = binKey(providerId) const [globalBin, setGlobalBin] = useState("") @@ -84,18 +87,31 @@ export function ProviderBinSettings({ )} {/* Only Claude Code reports context-window usage (via the lich plugin), so - the footer readout toggle lives in its section, not the generic hub. */} + the footer readout toggles live in its section, not the generic hub. */} {providerId === "claude" && ( - - - + <> + + + + + + + + )} ) diff --git a/frontend/src/lib/cost-readout-store.test.ts b/frontend/src/lib/cost-readout-store.test.ts new file mode 100644 index 000000000..57edc64b4 --- /dev/null +++ b/frontend/src/lib/cost-readout-store.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { costReadoutStore, resetCostReadoutStore, setCostReadout } from "./cost-readout-store" + +// The stored setting is the boundary; stub it so the test can assert both what +// the store reads and what a toggle writes back. +const getSetting = vi.fn() +const setSetting = vi.fn() +vi.mock("@/lib/rpc", () => ({ + Store: { + GetSetting: (key: string, projectID: string) => getSetting(key, projectID), + SetSetting: (key: string, projectID: string, value: string) => + setSetting(key, projectID, value), + }, +})) + +// flush lets the store's one load settle. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +beforeEach(() => { + resetCostReadoutStore() + getSetting.mockReset().mockResolvedValue("") + setSetting.mockReset().mockResolvedValue(null) +}) + +describe("costReadoutStore", () => { + // Off is what a subscription plan must see, and it is also what the page + // shows for the instant before the stored value arrives. + it("reads off before anything is loaded", () => { + expect(costReadoutStore.get()).toBe(false) + }) + + it("loads the stored flag on the first subscriber", async () => { + getSetting.mockResolvedValue("true") + let notified = 0 + costReadoutStore.subscribe(() => notified++) + + await flush() + + expect(getSetting).toHaveBeenCalledWith("usage.cost", "") + expect(costReadoutStore.get()).toBe(true) + expect(notified).toBe(1) + }) + + it("loads once however many subscribers mount", async () => { + costReadoutStore.subscribe(() => {}) + costReadoutStore.subscribe(() => {}) + costReadoutStore.subscribe(() => {}) + + await flush() + + expect(getSetting).toHaveBeenCalledTimes(1) + }) + + it("treats anything but the stored true as off", async () => { + getSetting.mockResolvedValue("1") + costReadoutStore.subscribe(() => {}) + + await flush() + + expect(costReadoutStore.get()).toBe(false) + }) + + // An unreachable backend must not turn a money readout on by accident. + it("stays off when the setting cannot be read", async () => { + getSetting.mockRejectedValue(new Error("no backend")) + costReadoutStore.subscribe(() => {}) + + await flush() + + expect(costReadoutStore.get()).toBe(false) + }) + + it("writes a toggle through and updates the page at once", () => { + let notified = 0 + costReadoutStore.subscribe(() => notified++) + + setCostReadout(true) + + expect(costReadoutStore.get()).toBe(true) + expect(notified).toBe(1) + expect(setSetting).toHaveBeenCalledWith("usage.cost", "", "true") + }) + + it("stops notifying an unsubscribed listener", () => { + let notified = 0 + const off = costReadoutStore.subscribe(() => notified++) + off() + + setCostReadout(true) + + expect(notified).toBe(0) + }) +}) diff --git a/frontend/src/lib/cost-readout-store.ts b/frontend/src/lib/cost-readout-store.ts new file mode 100644 index 000000000..04488d7e3 --- /dev/null +++ b/frontend/src/lib/cost-readout-store.ts @@ -0,0 +1,72 @@ +import { Store } from "@/lib/rpc" + +// Whether the footer shows what a session has cost. The truth lives in the +// backend settings table, not in localStorage, because the flag gates work and +// not just paint: with it off no transcript is summed and no price is ever +// fetched (see internal/store settings.CostReadout). This store is the page's +// copy of it — loaded once, written through on every toggle — so turning the +// readout off clears the number immediately instead of at the next turn. +// +// Off is the honest default while the value is still loading: the figure only +// means something on API billing, so nobody should see one appear and vanish. + +// SETTING_KEY and GLOBAL_SCOPE mirror the Go side's costReadoutKey and its +// global (project-less) scope. +const SETTING_KEY = "usage.cost" +const GLOBAL_SCOPE = "" + +let enabled = false +let loaded = false +const listeners = new Set<() => void>() + +const notify = () => { + for (const listener of listeners) { + listener() + } +} + +// load reads the stored flag once per page. A failed read leaves it off, which +// is what an unreachable backend should show for a number about money. +const load = async () => { + try { + const value = await Store.GetSetting(SETTING_KEY, GLOBAL_SCOPE) + const next = value === "true" + if (next !== enabled) { + enabled = next + notify() + } + } catch { + // Keep the default; the toggle in Settings still works and will write. + } +} + +export const costReadoutStore = { + get: (): boolean => enabled, + subscribe(listener: () => void): () => void { + listeners.add(listener) + if (!loaded) { + loaded = true + void load() + } + return () => { + listeners.delete(listener) + } + }, +} + +// setCostReadout flips the flag for good: the page updates at once and the +// backend stops (or starts) counting from its next turn. +export function setCostReadout(on: boolean): void { + if (on !== enabled) { + enabled = on + notify() + } + void Store.SetSetting(SETTING_KEY, GLOBAL_SCOPE, String(on)) +} + +// resetCostReadoutStore drops the module state between tests. +export function resetCostReadoutStore(): void { + enabled = false + loaded = false + listeners.clear() +} diff --git a/frontend/src/lib/session/session-cost.test.ts b/frontend/src/lib/session/session-cost.test.ts new file mode 100644 index 000000000..f973de130 --- /dev/null +++ b/frontend/src/lib/session/session-cost.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import { formatCost } from "./session-cost" + +describe("formatCost", () => { + it("shows cents once there are dollars to anchor them", () => { + expect(formatCost(12.476)).toBe("$12.48") + expect(formatCost(1)).toBe("$1.00") + }) + + it("keeps three decimals below a dollar, where the early signal lives", () => { + expect(formatCost(0.42)).toBe("$0.420") + expect(formatCost(0.004)).toBe("$0.004") + }) + + // A row of zeros would read as free. The floor says "small", not "nothing". + it("floors a fraction of a cent instead of rounding it away", () => { + expect(formatCost(0.0004)).toBe("<$0.001") + }) + + it("shows a true zero as zero", () => { + expect(formatCost(0)).toBe("$0") + }) + + // The value crosses a process boundary; a negative or non-finite total is a + // bug upstream, and rendering nothing beats rendering nonsense about money. + it("renders nothing for a value that cannot be a cost", () => { + expect(formatCost(-1)).toBe("") + expect(formatCost(Number.NaN)).toBe("") + expect(formatCost(Number.POSITIVE_INFINITY)).toBe("") + }) +}) diff --git a/frontend/src/lib/session/session-cost.ts b/frontend/src/lib/session/session-cost.ts new file mode 100644 index 000000000..019a8a98d --- /dev/null +++ b/frontend/src/lib/session/session-cost.ts @@ -0,0 +1,20 @@ +// formatCost renders a session's spend for the footer, where it sits beside the +// context percent at 12px and has to stay readable at a glance. +// +// The scale it spans is wide — a one-question session costs fractions of a +// cent, a long one runs to dollars — so the precision follows the number: cents +// once there are dollars to anchor them, three decimals below that (the +// difference between $0.004 and $0.02 is the whole signal early in a session), +// and a floor of "<$0.001" rather than a row of zeros, which would read as free. +export function formatCost(usd: number): string { + if (!Number.isFinite(usd) || usd < 0) { + return "" + } + if (usd === 0) { + return "$0" + } + if (usd < 0.001) { + return "<$0.001" + } + return usd >= 1 ? `$${usd.toFixed(2)}` : `$${usd.toFixed(3)}` +} diff --git a/frontend/src/lib/session/session-events.ts b/frontend/src/lib/session/session-events.ts index f2a1b0056..9465b355d 100644 --- a/frontend/src/lib/session/session-events.ts +++ b/frontend/src/lib/session/session-events.ts @@ -36,16 +36,22 @@ export const AGENT_EVENT = "session-agent" // context-window usage (see terminal.usageEventName). Payload: { id, percent, // tokens, window, model, effort } — percent is 0–100 of the window, tokens the // raw input-side count, window the model's context size, model its id, effort -// the reasoning level ("" when the turn records none). +// the reasoning level ("" when the turn records none) — plus an optional +// costUsd, which is present only when the cost readout is on and every model in +// the session has a known price. Its absence is the answer, not a zero. export const USAGE_EVENT = "session-usage" -// A session's context-window occupancy as the footer shows it. +// A session's context-window occupancy as the footer shows it, and what the +// session has cost so far. costUsd is null whenever the backend sent no number: +// the setting is off (the case on a subscription, where the figure is noise) or +// a model has no price yet. Nothing is rendered for it then. export interface SessionUsage { percent: number tokens: number window: number model: string effort: string + costUsd: number | null } // The states a card renders an indicator for. The contract also defines "idle" @@ -95,6 +101,7 @@ export function isUsageEvent(data: unknown): data is { window: number model: string effort: string + costUsd?: number } { return ( isIdEvent(data) && @@ -106,6 +113,14 @@ export function isUsageEvent(data: unknown): data is { ) } +// usageCost reads the optional cost off a usage payload. Absent is the common +// case (the readout is off for everyone who is not billed per token), and +// anything that is not a finite number is treated as absent rather than shown: +// a total is either trustworthy or not rendered. +export function usageCost(data: { costUsd?: unknown }): number | null { + return typeof data.costUsd === "number" && Number.isFinite(data.costUsd) ? data.costUsd : null +} + // shouldToastAttention decides whether a session needing the user deserves the // global toast. The session already in focus (active session of the active // project) does not: its own terminal shows the prompt. Every other session diff --git a/frontend/src/lib/session/session-usage-store.test.ts b/frontend/src/lib/session/session-usage-store.test.ts index 51111e713..914200a47 100644 --- a/frontend/src/lib/session/session-usage-store.test.ts +++ b/frontend/src/lib/session/session-usage-store.test.ts @@ -30,6 +30,7 @@ describe("createSessionUsageStore", () => { window: 200000, model: opus, effort: eff, + costUsd: null, }) expect(store.get("s2")).toEqual({ percent: 5, @@ -37,6 +38,7 @@ describe("createSessionUsageStore", () => { window: 1000000, model: opus, effort: eff, + costUsd: null, }) }) @@ -75,6 +77,7 @@ describe("createSessionUsageStore", () => { window: 200000, model: opus, effort: "xhigh", + costUsd: null, }) }) @@ -89,9 +92,51 @@ describe("createSessionUsageStore", () => { window: 200000, model: opus, effort: eff, + costUsd: null, }) }) + it("carries the session cost when the backend sent one", () => { + const { store, emit } = harness() + emit({ + id: "s1", + percent: 10, + tokens: 20000, + window: 200000, + model: opus, + effort: eff, + costUsd: 1.25, + }) + expect(store.get("s1")?.costUsd).toBe(1.25) + }) + + // The backend omits the field entirely rather than sending 0 — on a + // subscription, or for a model nothing can price. Reading it as null is what + // keeps the footer from claiming a session was free. + it("reads an absent or unusable cost as null", () => { + const { store, emit } = harness() + const base = { id: "s1", percent: 10, tokens: 20000, window: 200000, model: opus, effort: eff } + + emit(base) + expect(store.get("s1")?.costUsd).toBeNull() + + emit({ ...base, percent: 11, costUsd: "1.25" }) + expect(store.get("s1")?.costUsd).toBeNull() + + emit({ ...base, percent: 12, costUsd: Number.NaN }) + expect(store.get("s1")?.costUsd).toBeNull() + }) + + it("re-renders when only the cost moved", () => { + const { store, emit } = harness() + let calls = 0 + store.subscribe("s1", () => calls++) + const base = { id: "s1", percent: 10, tokens: 20000, window: 200000, model: opus, effort: eff } + emit({ ...base, costUsd: 0.1 }) + emit({ ...base, costUsd: 0.2 }) + expect(calls).toBe(2) + }) + it("ignores malformed payloads", () => { const { store, emit } = harness() emit({ id: "s1", percent: 10, tokens: 20000, window: 200000, model: opus }) diff --git a/frontend/src/lib/session/session-usage-store.ts b/frontend/src/lib/session/session-usage-store.ts index 053449987..04af1d973 100644 --- a/frontend/src/lib/session/session-usage-store.ts +++ b/frontend/src/lib/session/session-usage-store.ts @@ -1,5 +1,5 @@ import { createKeyedStore, type ReadableKeyedStore } from "@/lib/keyed-store" -import { isUsageEvent, type SessionUsage } from "./session-events" +import { isUsageEvent, usageCost, type SessionUsage } from "./session-events" // A subscription to the global usage event, injected so the store is testable // without standing up the /events socket. Returns its unsubscribe. @@ -16,7 +16,8 @@ const sameUsage = (a: SessionUsage | null, b: SessionUsage | null): boolean => a.tokens === b.tokens && a.window === b.window && a.model === b.model && - a.effort === b.effort) + a.effort === b.effort && + a.costUsd === b.costUsd) // createSessionUsageStore keeps the last reported context-window usage of every // session, keyed by session id, fed by one subscription taken at creation — @@ -36,6 +37,7 @@ export function createSessionUsageStore( window: data.window, model: data.model, effort: data.effort, + costUsd: usageCost(data), }) }) return store diff --git a/frontend/src/lib/use-cost-readout.ts b/frontend/src/lib/use-cost-readout.ts new file mode 100644 index 000000000..cc245c254 --- /dev/null +++ b/frontend/src/lib/use-cost-readout.ts @@ -0,0 +1,9 @@ +import { useSyncExternalStore } from "react" +import { costReadoutStore } from "./cost-readout-store" + +// useCostReadout reports whether the per-session cost readout is on. False +// until the stored setting has been read, so the footer never flashes a number +// at someone who turned it off. +export function useCostReadout(): boolean { + return useSyncExternalStore(costReadoutStore.subscribe, costReadoutStore.get) +} diff --git a/internal/pricing/prices.json b/internal/pricing/prices.json new file mode 100644 index 000000000..ec9099272 --- /dev/null +++ b/internal/pricing/prices.json @@ -0,0 +1,146 @@ +{ + "claude-3-7-sonnet-20250219": { + "input": 3e-06, + "output": 1.5e-05, + "cacheRead": 3e-07, + "cacheWrite": 3.75e-06 + }, + "claude-3-haiku-20240307": { + "input": 2.5e-07, + "output": 1.25e-06, + "cacheRead": 3e-08, + "cacheWrite": 3e-07 + }, + "claude-3-opus-20240229": { + "input": 1.5e-05, + "output": 7.5e-05, + "cacheRead": 1.5e-06, + "cacheWrite": 1.875e-05 + }, + "claude-4-opus-20250514": { + "input": 1.5e-05, + "output": 7.5e-05, + "cacheRead": 1.5e-06, + "cacheWrite": 1.875e-05 + }, + "claude-4-sonnet-20250514": { + "input": 3e-06, + "output": 1.5e-05, + "cacheRead": 3e-07, + "cacheWrite": 3.75e-06 + }, + "claude-fable-5": { + "input": 1e-05, + "output": 5e-05, + "cacheRead": 1e-06, + "cacheWrite": 1.25e-05 + }, + "claude-haiku-4-5": { + "input": 1e-06, + "output": 5e-06, + "cacheRead": 1e-07, + "cacheWrite": 1.25e-06 + }, + "claude-haiku-4-5-20251001": { + "input": 1e-06, + "output": 5e-06, + "cacheRead": 1e-07, + "cacheWrite": 1.25e-06 + }, + "claude-opus-4-1": { + "input": 1.5e-05, + "output": 7.5e-05, + "cacheRead": 1.5e-06, + "cacheWrite": 1.875e-05 + }, + "claude-opus-4-1-20250805": { + "input": 1.5e-05, + "output": 7.5e-05, + "cacheRead": 1.5e-06, + "cacheWrite": 1.875e-05 + }, + "claude-opus-4-20250514": { + "input": 1.5e-05, + "output": 7.5e-05, + "cacheRead": 1.5e-06, + "cacheWrite": 1.875e-05 + }, + "claude-opus-4-5": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-opus-4-5-20251101": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-opus-4-6": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-opus-4-6-20260205": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-opus-4-7": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-opus-4-7-20260416": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-opus-4-8": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-opus-5": { + "input": 5e-06, + "output": 2.5e-05, + "cacheRead": 5e-07, + "cacheWrite": 6.25e-06 + }, + "claude-sonnet-4-20250514": { + "input": 3e-06, + "output": 1.5e-05, + "cacheRead": 3e-07, + "cacheWrite": 3.75e-06 + }, + "claude-sonnet-4-5": { + "input": 3e-06, + "output": 1.5e-05, + "cacheRead": 3e-07, + "cacheWrite": 3.75e-06 + }, + "claude-sonnet-4-5-20250929": { + "input": 3e-06, + "output": 1.5e-05, + "cacheRead": 3e-07, + "cacheWrite": 3.75e-06 + }, + "claude-sonnet-4-6": { + "input": 3e-06, + "output": 1.5e-05, + "cacheRead": 3e-07, + "cacheWrite": 3.75e-06 + }, + "claude-sonnet-5": { + "input": 2e-06, + "output": 1e-05, + "cacheRead": 2e-07, + "cacheWrite": 2.5e-06 + } +} diff --git a/internal/pricing/pricing.go b/internal/pricing/pricing.go new file mode 100644 index 000000000..33ba963cd --- /dev/null +++ b/internal/pricing/pricing.go @@ -0,0 +1,298 @@ +// Package pricing answers what a model's tokens cost, in US dollars, for the +// per-session cost readout. It is keyed by model id and knows nothing about who +// ran the model: a second provider's reader gets its prices from the same table. +// +// The table has two layers. A small baked one (prices.json, the Claude models +// known at build time) is the floor, so a fresh install prices correctly with no +// network at all. A model the floor has never heard of — one released after this +// build — triggers a single remote refresh, whose result is cached under the +// config dir and overlays the floor from then on. Nothing here blocks a caller: +// the refresh runs in the background and the lookup that missed simply reports +// "unknown", which the readout renders as absent rather than as zero. Pricing a +// model at nothing is the one answer this package must never give. +package pricing + +import ( + _ "embed" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "regexp" + "sync" + "time" +) + +//go:embed prices.json +var bakedPrices []byte + +const ( + // remoteURL is LiteLLM's price table — the one machine-readable source that + // tracks every provider's rates (Anthropic publishes none), kept current by a + // project with its own reason to care. Fetched at most once per unknown model + // per run, never on a schedule. + remoteURL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" + + // httpTimeout bounds the refresh; bodyLimit bounds what is read from it (the + // table is ~2 MiB and grows with the industry, so the cap is slack). + httpTimeout = 15 * time.Second + bodyLimit = 16 << 20 + + // cacheFile holds the refreshed table under the lich config dir. + cacheFile = "prices.json" +) + +// Rate is one model's price per token, in USD, split the way a provider bills +// them: fresh input, generated output, a cache hit, and writing the cache. +type Rate struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cacheRead"` + CacheWrite float64 `json:"cacheWrite"` +} + +// Tokens is a turn's (or a whole conversation's) token counts, named after the +// four counters a provider transcript reports. +type Tokens struct { + Input int + Output int + CacheRead int + CacheWrite int +} + +// Cost is what these tokens cost at this rate, in USD. +func (r Rate) Cost(t Tokens) float64 { + return r.Input*float64(t.Input) + + r.Output*float64(t.Output) + + r.CacheRead*float64(t.CacheRead) + + r.CacheWrite*float64(t.CacheWrite) +} + +// Table resolves model ids to rates. The zero value is not usable — call New. +type Table struct { + mu sync.RWMutex + rates map[string]Rate + // asked records the model ids a refresh has already been spent on, so a model + // that stays unknown (a local proxy, a private deployment) costs one request + // per run and not one per turn. + asked map[string]bool + // refreshing is held for the duration of a refresh so concurrent misses share + // the one in-flight request. + refreshing bool + + http *http.Client + url string + cachePath string +} + +// New returns a table backed by the baked prices, overlaid with the cached +// refresh if one was written by an earlier run. +func New() *Table { + return newTable(remoteURL, cachePath(), &http.Client{Timeout: httpTimeout}) +} + +// newTable builds a table against a given source and cache location — the seam +// the suite drives instead of the real network. A missing or corrupt cache is +// not an error: the baked floor answers alone. +func newTable(url, cachePath string, client *http.Client) *Table { + t := &Table{ + rates: parseRates(bakedPrices), + asked: make(map[string]bool), + http: client, + url: url, + cachePath: cachePath, + } + if data, err := os.ReadFile(cachePath); err == nil { + t.merge(parseRates(data)) + } + return t +} + +// Rate returns the model's price per token. ok is false for a model the table +// cannot price — the caller must report no cost at all, never a zero one — and +// schedules the one refresh that may teach it, so the next turn can answer. +func (t *Table) Rate(model string) (Rate, bool) { + if model == "" { + return Rate{}, false + } + if rate, ok := t.lookup(model); ok { + return rate, true + } + go t.refreshFor(model) + return Rate{}, false +} + +// lookup resolves a model id against the table: the id as reported, then with a +// dated suffix stripped ("claude-opus-4-5-20251101" → "claude-opus-4-5"), which +// is how a provider names one model in two places. +func (t *Table) lookup(model string) (Rate, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + if rate, ok := t.rates[model]; ok { + return rate, true + } + rate, ok := t.rates[undated(model)] + return rate, ok +} + +// datedSuffix matches the "-20251101" a provider appends to pin a model release. +var datedSuffix = regexp.MustCompile(`-\d{8}$`) + +func undated(model string) string { + return datedSuffix.ReplaceAllString(model, "") +} + +// refreshFor fetches the remote table once for a model the baked floor and the +// cache both miss. Every failure is silent past a debug line: the caller has +// already reported "unknown", which is the honest answer whether or not this +// works. +func (t *Table) refreshFor(model string) { + if !t.claimRefresh(model) { + return + } + defer t.releaseRefresh() + + rates, err := t.fetch() + if err != nil { + slog.Debug("pricing: refresh failed", "model", model, "err", err) + return + } + t.merge(rates) + t.writeCache(rates) +} + +// claimRefresh reports whether this miss should run the refresh: it must be the +// first miss for that model, and no other refresh may be in flight. +func (t *Table) claimRefresh(model string) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.asked[model] || t.refreshing { + return false + } + t.asked[model] = true + t.refreshing = true + return true +} + +func (t *Table) releaseRefresh() { + t.mu.Lock() + t.refreshing = false + t.mu.Unlock() +} + +// merge overlays newer rates on the table. Remote wins: it is the layer that +// exists because the baked one went stale. +func (t *Table) merge(rates map[string]Rate) { + t.mu.Lock() + defer t.mu.Unlock() + for model, rate := range rates { + t.rates[model] = rate + } +} + +// fetch downloads the remote table and reduces it to lich's shape — every entry +// that carries a real price, whatever provider it belongs to. +func (t *Table) fetch() (map[string]Rate, error) { + req, err := http.NewRequest(http.MethodGet, t.url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "lich") + resp, err := t.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit)) + if err != nil { + return nil, err + } + rates, err := parseRemote(data) + if err != nil { + return nil, err + } + if len(rates) == 0 { + return nil, fmt.Errorf("no priced models in the remote table") + } + return rates, nil +} + +// remoteEntry is the subset of a LiteLLM table entry lich reads. Its other forty +// fields are ignored, so a change to any of them cannot break this. +type remoteEntry struct { + Input float64 `json:"input_cost_per_token"` + Output float64 `json:"output_cost_per_token"` + CacheRead float64 `json:"cache_read_input_token_cost"` + CacheWrite float64 `json:"cache_creation_input_token_cost"` +} + +// parseRemote converts the remote table to lich's shape, dropping every entry +// priced at nothing. A free model and a model whose price nobody filled in look +// identical here, and "unknown" is the safe reading of both. +func parseRemote(data []byte) (map[string]Rate, error) { + var raw map[string]remoteEntry + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + rates := make(map[string]Rate, len(raw)) + for model, entry := range raw { + if entry.Input == 0 && entry.Output == 0 { + continue + } + rates[model] = Rate{ + Input: entry.Input, + Output: entry.Output, + CacheRead: entry.CacheRead, + CacheWrite: entry.CacheWrite, + } + } + return rates, nil +} + +// parseRates reads lich's own shape (the baked file and the cache). Unparsable +// content yields an empty table rather than an error: both callers treat "no +// rates from this layer" as the miss it is. +func parseRates(data []byte) map[string]Rate { + rates := make(map[string]Rate) + if err := json.Unmarshal(data, &rates); err != nil { + slog.Debug("pricing: unreadable price table", "err", err) + return make(map[string]Rate) + } + return rates +} + +// writeCache saves the refreshed table so the next run starts already knowing +// the models this one had to ask about. Best effort — an unwritable config dir +// costs one request per run, nothing more. +func (t *Table) writeCache(rates map[string]Rate) { + if t.cachePath == "" { + return + } + data, err := json.Marshal(rates) + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(t.cachePath), 0o755); err != nil { + slog.Debug("pricing: cache directory", "err", err) + return + } + if err := os.WriteFile(t.cachePath, data, 0o644); err != nil { + slog.Debug("pricing: write cache", "err", err) + } +} + +// cachePath is /lich/prices.json, or "" when the OS has no config +// dir to offer — the cache is then skipped and the baked floor stands. +func cachePath() string { + dir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(dir, "lich", cacheFile) +} diff --git a/internal/pricing/pricing_test.go b/internal/pricing/pricing_test.go new file mode 100644 index 000000000..55333f46c --- /dev/null +++ b/internal/pricing/pricing_test.go @@ -0,0 +1,243 @@ +package pricing + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" +) + +// remoteBody is the shape LiteLLM publishes, cut to the fields lich reads: two +// priced models, one free (a placeholder nobody filled in), and the noise field +// every real entry carries. +const remoteBody = `{ + "claude-opus-9": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "cache_read_input_token_cost": 1e-06, + "cache_creation_input_token_cost": 1.25e-05, + "litellm_provider": "anthropic", + "supports_pdf_input": true + }, + "gpt-9": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 8e-06, + "litellm_provider": "openai" + }, + "sample_spec": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0 + } +}` + +// serveRemote stands in for the published table and counts what asked for it. +func serveRemote(t *testing.T, body string) (*httptest.Server, *atomic.Int64) { + t.Helper() + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, &hits +} + +// TestBakedTablePricesAShippedModel proves the floor answers with no network +// and no cache: a fresh install prices the models it shipped knowing. +func TestBakedTablePricesAShippedModel(t *testing.T) { + table := newTable("http://0.0.0.0:0", filepath.Join(t.TempDir(), "prices.json"), http.DefaultClient) + + rate, ok := table.Rate("claude-opus-4-5") + if !ok { + t.Fatal("Rate(claude-opus-4-5): want a price from the baked table") + } + if rate.Input != 5e-06 || rate.Output != 2.5e-05 { + t.Errorf("Rate = %+v, want the published opus-4-5 rates", rate) + } +} + +// TestDatedModelIdResolvesToItsFamily proves the id a transcript records +// ("…-20251101", pinning a release) prices off the undated entry. Without it +// every dated id would read as unknown and the readout would never appear. +func TestDatedModelIdResolvesToItsFamily(t *testing.T) { + table := newTable("http://0.0.0.0:0", filepath.Join(t.TempDir(), "prices.json"), http.DefaultClient) + + // A date the baked table has no exact entry for, so only the strip can + // answer — the shape a model release takes between two lich builds. + dated, ok := table.Rate("claude-sonnet-4-5-20260714") + if !ok { + t.Fatal("Rate: a dated id must resolve to its family") + } + undatedRate, _ := table.Rate("claude-sonnet-4-5") + if dated != undatedRate { + t.Errorf("dated rate = %+v, want the same as undated %+v", dated, undatedRate) + } +} + +// TestUnknownModelIsUnpricedNotFree is the invariant the whole feature rests +// on: a model the table has never heard of reports no price, so the caller +// shows no number. Pricing it at zero would render a running total of $0.00 +// over a session that cost real money. +func TestUnknownModelIsUnpricedNotFree(t *testing.T) { + srv, _ := serveRemote(t, remoteBody) + table := newTable(srv.URL, filepath.Join(t.TempDir(), "prices.json"), srv.Client()) + + if rate, ok := table.Rate("some-private-deployment"); ok { + t.Errorf("Rate = %+v, ok=true; want an unpriced miss", rate) + } + if rate, ok := table.Rate(""); ok { + t.Errorf("Rate(\"\") = %+v, ok=true; want an unpriced miss", rate) + } +} + +// TestRefreshTeachesAModelReleasedAfterTheBuild is the reason the remote layer +// exists: a model that ships after this binary did prices correctly once the +// refresh lands, without a release of lich. +func TestRefreshTeachesAModelReleasedAfterTheBuild(t *testing.T) { + srv, hits := serveRemote(t, remoteBody) + table := newTable(srv.URL, filepath.Join(t.TempDir(), "prices.json"), srv.Client()) + + // lookup, not Rate: Rate's miss starts the refresh itself, and asserting on + // the synchronous one below while that goroutine is still running is how a + // test like this goes flaky. + if _, ok := table.lookup("claude-opus-9"); ok { + t.Fatal("lookup: the baked table cannot know a model published after it") + } + table.refreshFor("claude-opus-9") + + rate, ok := table.Rate("claude-opus-9") + if !ok { + t.Fatal("Rate after refresh: want the freshly published price") + } + if rate.Input != 1e-05 || rate.CacheWrite != 1.25e-05 { + t.Errorf("Rate = %+v, want the remote opus-9 rates", rate) + } + if got := hits.Load(); got != 1 { + t.Errorf("remote hits = %d, want exactly 1", got) + } +} + +// TestRefreshKeepsEveryProvidersPrices proves the table stays provider-agnostic: +// the reader is Claude-only today, but the prices a second reader will need are +// already there — nothing filters the remote table down to one vendor. +func TestRefreshKeepsEveryProvidersPrices(t *testing.T) { + srv, _ := serveRemote(t, remoteBody) + table := newTable(srv.URL, filepath.Join(t.TempDir(), "prices.json"), srv.Client()) + + table.refreshFor("gpt-9") + + if _, ok := table.Rate("gpt-9"); !ok { + t.Error("Rate(gpt-9): a non-Claude model must price from the same table") + } +} + +// TestAZeroPricedEntryIsDropped pins the read of an unpriced remote entry: the +// documentation placeholder and a model nobody filled in both cost 0.0 there, +// and "unknown" is the only safe reading of both. +func TestAZeroPricedEntryIsDropped(t *testing.T) { + srv, _ := serveRemote(t, remoteBody) + table := newTable(srv.URL, filepath.Join(t.TempDir(), "prices.json"), srv.Client()) + + table.refreshFor("sample_spec") + + if rate, ok := table.Rate("sample_spec"); ok { + t.Errorf("Rate(sample_spec) = %+v, ok=true; want it dropped as unpriced", rate) + } +} + +// TestAModelIsOnlyAskedAboutOnce proves the miss path cannot turn into a +// request per turn: emitUsage runs on every non-idle hook, and a model that +// stays unknown (a proxy, a private deployment) must cost one request per run. +func TestAModelIsOnlyAskedAboutOnce(t *testing.T) { + srv, hits := serveRemote(t, remoteBody) + table := newTable(srv.URL, filepath.Join(t.TempDir(), "prices.json"), srv.Client()) + + table.refreshFor("still-unknown") + table.refreshFor("still-unknown") + table.refreshFor("still-unknown") + + if got := hits.Load(); got != 1 { + t.Errorf("remote hits = %d, want exactly 1", got) + } +} + +// TestTheRefreshIsCachedForTheNextRun proves the fetched table survives a +// restart: the second run answers for a post-build model with the server gone. +func TestTheRefreshIsCachedForTheNextRun(t *testing.T) { + srv, _ := serveRemote(t, remoteBody) + cache := filepath.Join(t.TempDir(), "cache", "prices.json") + + first := newTable(srv.URL, cache, srv.Client()) + first.refreshFor("claude-opus-9") + + next := newTable("http://0.0.0.0:0", cache, http.DefaultClient) + if _, ok := next.Rate("claude-opus-9"); !ok { + t.Error("Rate: the cached refresh must answer without the network") + } +} + +// TestACorruptCacheLeavesTheBakedFloorStanding proves a half-written cache +// cannot take the readout down with it — the layer below still answers. +func TestACorruptCacheLeavesTheBakedFloorStanding(t *testing.T) { + cache := filepath.Join(t.TempDir(), "prices.json") + if err := os.WriteFile(cache, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + + table := newTable("http://0.0.0.0:0", cache, http.DefaultClient) + + if _, ok := table.Rate("claude-opus-4-5"); !ok { + t.Error("Rate: a corrupt cache must not shadow the baked prices") + } +} + +// TestAFailedRefreshChangesNothing pins the offline path: an unreachable or +// broken source leaves the table exactly as it was, and the caller keeps +// reporting "unknown" rather than a guess. +func TestAFailedRefreshChangesNothing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + table := newTable(srv.URL, filepath.Join(t.TempDir(), "prices.json"), srv.Client()) + + table.refreshFor("claude-opus-9") + + if _, ok := table.Rate("claude-opus-9"); ok { + t.Error("Rate: a failed refresh must not invent a price") + } + if _, ok := table.Rate("claude-opus-4-5"); !ok { + t.Error("Rate: a failed refresh must not lose the prices already held") + } +} + +// TestCostBillsEveryCounterSeparately proves the four counters are priced at +// their own rates — a cache read is an order of magnitude cheaper than fresh +// input, and folding them together would overstate a long session badly. +func TestCostBillsEveryCounterSeparately(t *testing.T) { + rate := Rate{Input: 1e-05, Output: 5e-05, CacheRead: 1e-06, CacheWrite: 1.25e-05} + + got := rate.Cost(Tokens{Input: 100, Output: 200, CacheRead: 300, CacheWrite: 400}) + + want := 100*1e-05 + 200*5e-05 + 300*1e-06 + 400*1.25e-05 + if got != want { + t.Errorf("Cost = %v, want %v", got, want) + } +} + +// TestEveryBakedRateIsPriced guards the generated table itself: an entry that +// lost its rates in a regeneration would price a session at zero, which is the +// one answer this package must never give. +func TestEveryBakedRateIsPriced(t *testing.T) { + rates := parseRates(bakedPrices) + if len(rates) == 0 { + t.Fatal("the baked table is empty") + } + for model, rate := range rates { + if rate.Input <= 0 || rate.Output <= 0 { + t.Errorf("baked rate for %q = %+v, want real input and output prices", model, rate) + } + } +} diff --git a/internal/store/cost.go b/internal/store/cost.go new file mode 100644 index 000000000..5daf3e0ad --- /dev/null +++ b/internal/store/cost.go @@ -0,0 +1,119 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +// CostLedger returns how far a session's cost accounting has read into one of +// its provider transcripts: the byte offset counted up to, the message id the +// last counted line belonged to, and what that stretch cost in USD. +// +// A session accumulates one ledger per transcript it has run through — `/clear` +// starts a new conversation, and its transcript gets its own row while the old +// one keeps what it already counted. A pair nothing has been counted for yet +// reads as offset 0 and cost 0, the correct starting point, so a missing row is +// not an error. +func (s *Service) CostLedger(sessionID, transcriptID string) (int64, string, float64, error) { + var ( + offset int64 + lastMessage string + cost float64 + ) + err := s.db.QueryRow( + `SELECT byte_offset, last_message_id, cost_usd FROM session_costs + WHERE session_id = ? AND transcript_id = ?`, + sessionID, transcriptID, + ).Scan(&offset, &lastMessage, &cost) + if errors.Is(err, sql.ErrNoRows) { + return 0, "", 0, nil + } + if err != nil { + return 0, "", 0, fmt.Errorf("read cost ledger for %q: %w", sessionID, err) + } + return offset, lastMessage, cost, nil +} + +// SaveCostLedger records the position and total of one transcript's accounting. +// The SELECT ... WHERE EXISTS writes nothing for a session whose row is already +// gone (closed while its last turn was still being counted) instead of letting +// the foreign key raise: that is a race, not a failure, and it is the same +// silent miss SetProviderSession takes. +func (s *Service) SaveCostLedger( + sessionID, transcriptID string, offset int64, lastMessage string, cost float64, +) error { + _, err := s.db.Exec( + `INSERT INTO session_costs (session_id, transcript_id, byte_offset, last_message_id, cost_usd) + SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM sessions WHERE id = ?) + ON CONFLICT(session_id, transcript_id) DO UPDATE SET + byte_offset = excluded.byte_offset, + last_message_id = excluded.last_message_id, + cost_usd = excluded.cost_usd`, + sessionID, transcriptID, offset, lastMessage, cost, sessionID, + ) + if err != nil { + return fmt.Errorf("save cost ledger for %q: %w", sessionID, err) + } + return nil +} + +// costRow is one session_costs row, carried across the delete/reinsert a +// parked session's resume performs (see ReopenWorktreeSession). +type costRow struct { + transcriptID string + offset int64 + lastMessage string + cost float64 +} + +// costLedgers reads every ledger a session holds. +func costLedgers(tx *sql.Tx, sessionID string) ([]costRow, error) { + rows, err := tx.Query( + `SELECT transcript_id, byte_offset, last_message_id, cost_usd + FROM session_costs WHERE session_id = ?`, sessionID, + ) + if err != nil { + return nil, fmt.Errorf("read cost ledgers for %q: %w", sessionID, err) + } + defer func() { _ = rows.Close() }() + + var ledgers []costRow + for rows.Next() { + var row costRow + if err := rows.Scan(&row.transcriptID, &row.offset, &row.lastMessage, &row.cost); err != nil { + return nil, fmt.Errorf("scan cost ledger for %q: %w", sessionID, err) + } + ledgers = append(ledgers, row) + } + return ledgers, rows.Err() +} + +// restoreCostLedgers re-keys ledgers onto a session id. +func restoreCostLedgers(tx *sql.Tx, sessionID string, ledgers []costRow) error { + for _, row := range ledgers { + if _, err := tx.Exec( + `INSERT INTO session_costs (session_id, transcript_id, byte_offset, last_message_id, cost_usd) + VALUES (?, ?, ?, ?, ?)`, + sessionID, row.transcriptID, row.offset, row.lastMessage, row.cost, + ); err != nil { + return fmt.Errorf("restore cost ledger for %q: %w", sessionID, err) + } + } + return nil +} + +// SessionCost is what a session has cost so far: the sum of every transcript it +// has run through. Zero for a session that has counted nothing yet — whether +// that zero is worth showing is the caller's call, since an unpriced model must +// read as "no number", never as free. +func (s *Service) SessionCost(sessionID string) (float64, error) { + var cost sql.NullFloat64 + err := s.db.QueryRow( + `SELECT SUM(cost_usd) FROM session_costs WHERE session_id = ?`, sessionID, + ).Scan(&cost) + if err != nil { + return 0, fmt.Errorf("read session cost for %q: %w", sessionID, err) + } + return cost.Float64, nil +} diff --git a/internal/store/cost_test.go b/internal/store/cost_test.go new file mode 100644 index 000000000..5aeec3f87 --- /dev/null +++ b/internal/store/cost_test.go @@ -0,0 +1,194 @@ +package store + +import "testing" + +// newCostSession opens a store with one project and one session to bill. +func newCostSession(t *testing.T) *Service { + t.Helper() + svc := newTestStore(t) + if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + t.Fatalf("AddProject: %v", err) + } + if err := svc.AddSession("p1", "s1", "Session 1", "", "", 2); err != nil { + t.Fatalf("AddSession: %v", err) + } + return svc +} + +// TestAnUncountedTranscriptStartsAtZero pins the starting point every session +// begins from: no row yet means offset 0, so the first scan reads the whole +// transcript instead of erroring. +func TestAnUncountedTranscriptStartsAtZero(t *testing.T) { + svc := newCostSession(t) + + offset, lastMessage, cost, err := svc.CostLedger("s1", "uuid-a") + + if err != nil { + t.Fatalf("CostLedger: %v", err) + } + if offset != 0 || lastMessage != "" || cost != 0 { + t.Errorf("ledger = (%d, %q, %v), want the zero starting point", offset, lastMessage, cost) + } +} + +// TestTheLedgerRoundTrips proves a saved position comes back intact — it is +// what makes the next scan resume rather than recount. +func TestTheLedgerRoundTrips(t *testing.T) { + svc := newCostSession(t) + + if err := svc.SaveCostLedger("s1", "uuid-a", 512, "msg-7", 0.25); err != nil { + t.Fatalf("SaveCostLedger: %v", err) + } + if err := svc.SaveCostLedger("s1", "uuid-a", 1024, "msg-9", 0.5); err != nil { + t.Fatalf("SaveCostLedger (update): %v", err) + } + + offset, lastMessage, cost, err := svc.CostLedger("s1", "uuid-a") + if err != nil { + t.Fatalf("CostLedger: %v", err) + } + if offset != 1024 || lastMessage != "msg-9" || cost != 0.5 { + t.Errorf("ledger = (%d, %q, %v), want the second save", offset, lastMessage, cost) + } +} + +// TestSessionCostSumsEveryConversation is the `/clear` contract at the storage +// layer: each conversation keeps its own row, and the session's number is all +// of them. +func TestSessionCostSumsEveryConversation(t *testing.T) { + svc := newCostSession(t) + if err := svc.SaveCostLedger("s1", "uuid-a", 100, "m1", 0.25); err != nil { + t.Fatalf("SaveCostLedger: %v", err) + } + if err := svc.SaveCostLedger("s1", "uuid-b", 100, "m2", 0.75); err != nil { + t.Fatalf("SaveCostLedger: %v", err) + } + + cost, err := svc.SessionCost("s1") + + if err != nil { + t.Fatalf("SessionCost: %v", err) + } + if cost != 1.0 { + t.Errorf("SessionCost = %v, want 1.0", cost) + } +} + +// TestASessionWithNothingCountedCostsZero pins the empty read: SUM over no rows +// is NULL in SQLite, and a NULL that reached the caller as an error would take +// the readout down for every session's first turn. +func TestASessionWithNothingCountedCostsZero(t *testing.T) { + svc := newCostSession(t) + + cost, err := svc.SessionCost("s1") + + if err != nil { + t.Fatalf("SessionCost: %v", err) + } + if cost != 0 { + t.Errorf("SessionCost = %v, want 0", cost) + } +} + +// TestClosingASessionForgetsItsCost proves the ledgers go with the session +// they belong to — the workspace never accumulates rows for sessions that are +// gone. +func TestClosingASessionForgetsItsCost(t *testing.T) { + svc := newCostSession(t) + if err := svc.SaveCostLedger("s1", "uuid-a", 100, "m1", 0.25); err != nil { + t.Fatalf("SaveCostLedger: %v", err) + } + + if err := svc.DeleteSession("p1", "s1", ""); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + + cost, err := svc.SessionCost("s1") + if err != nil { + t.Fatalf("SessionCost: %v", err) + } + if cost != 0 { + t.Errorf("SessionCost = %v, want 0 after the session was deleted", cost) + } +} + +// TestALedgerForAMissingSessionIsDropped pins the race a turn can lose: the +// session is closed while its last turn is still being counted. The write finds +// nothing to attach to and says nothing, rather than failing the readout. +func TestALedgerForAMissingSessionIsDropped(t *testing.T) { + svc := newCostSession(t) + + if err := svc.SaveCostLedger("gone", "uuid-a", 100, "m1", 0.25); err != nil { + t.Fatalf("SaveCostLedger: %v", err) + } + + cost, err := svc.SessionCost("gone") + if err != nil { + t.Fatalf("SessionCost: %v", err) + } + if cost != 0 { + t.Errorf("SessionCost = %v, want 0 — nothing should have been written", cost) + } +} + +// TestAResumedSessionKeepsWhatItSpent covers the park-and-reopen path, which +// re-keys the session under a fresh id. The conversation continues, so its +// total must too. +func TestAResumedSessionKeepsWhatItSpent(t *testing.T) { + svc := newTestStore(t) + if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + t.Fatalf("AddProject: %v", err) + } + if err := svc.AddSession("p1", "s1", "Session 1", "claude", "/tmp/alpha-wt", 2); err != nil { + t.Fatalf("AddSession: %v", err) + } + if err := svc.SaveCostLedger("s1", "uuid-a", 100, "m1", 0.4); err != nil { + t.Fatalf("SaveCostLedger: %v", err) + } + if err := svc.CloseSession("p1", "s1", ""); err != nil { + t.Fatalf("CloseSession: %v", err) + } + + restored, err := svc.ReopenWorktreeSession("p1", "/tmp/alpha-wt", "s2") + if err != nil { + t.Fatalf("ReopenWorktreeSession: %v", err) + } + if restored == nil { + t.Fatal("ReopenWorktreeSession: want the parked session back") + } + + cost, err := svc.SessionCost("s2") + if err != nil { + t.Fatalf("SessionCost: %v", err) + } + if cost != 0.4 { + t.Errorf("SessionCost = %v, want 0.4 carried onto the resumed session", cost) + } +} + +// TestCostReadoutIsOffUntilAskedFor is the feature's premise at the setting +// layer: the number only means something on API billing, so it is off for +// everyone who never turned it on, and anything but "true" reads as off. +func TestCostReadoutIsOffUntilAskedFor(t *testing.T) { + svc := newTestStore(t) + + if svc.CostReadout() { + t.Error("CostReadout = true on a fresh workspace, want off") + } + + for _, value := range []string{"1", "yes", "", "TRUE"} { + if err := svc.SetSetting("usage.cost", "", value); err != nil { + t.Fatalf("SetSetting: %v", err) + } + if svc.CostReadout() { + t.Errorf("CostReadout = true for %q, want off", value) + } + } + + if err := svc.SetSetting("usage.cost", "", "true"); err != nil { + t.Fatalf("SetSetting: %v", err) + } + if !svc.CostReadout() { + t.Error("CostReadout = false after it was turned on") + } +} diff --git a/internal/store/mutations.go b/internal/store/mutations.go index c814a0f03..fbfb6259f 100644 --- a/internal/store/mutations.go +++ b/internal/store/mutations.go @@ -138,6 +138,13 @@ func (s *Service) ReopenWorktreeSession(projectID, path, newSessionID string) (* } return fmt.Errorf("find parked session for %q: %w", path, err) } + // The cost ledgers are read out before the row goes, because deleting it + // cascades them away — and a resumed session that forgot what it spent + // would restate its total from zero over the same conversation. + ledgers, err := costLedgers(tx, old.ID) + if err != nil { + return err + } if _, err := tx.Exec(`DELETE FROM sessions WHERE id = ?`, old.ID); err != nil { return fmt.Errorf("drop parked session %q: %w", old.ID, err) } @@ -148,6 +155,9 @@ func (s *Service) ReopenWorktreeSession(projectID, path, newSessionID string) (* ); err != nil { return fmt.Errorf("reinsert session %q: %w", newSessionID, err) } + if err := restoreCostLedgers(tx, newSessionID, ledgers); err != nil { + return err + } if err := setActiveSession(tx, projectID, newSessionID); err != nil { return err } diff --git a/internal/store/settings.go b/internal/store/settings.go index 44072870c..a617a628c 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -124,6 +124,23 @@ func (s *Service) projectIDForPath(path string) string { return id } +// costReadoutKey is the settings key that turns the per-session cost readout +// on. Global only, and off unless it holds "true": the number means nothing on +// a subscription, so it is absent until someone billed per token asks for it. +// A backend setting rather than a UI preference because the flag also gates the +// work — off, no transcript is summed and no price is ever fetched. +const costReadoutKey = "usage.cost" + +// CostReadout reports whether the session cost readout is on. Any read failure +// answers off, which is the safe default for a number most users must not see. +func (s *Service) CostReadout() bool { + value, err := s.GetSetting(costReadoutKey, globalScope) + if err != nil { + return false + } + return value == "true" +} + // worktreeSetupKey is the settings key holding a project's worktree setup // script. Project-scoped only, no global fallback: a setup command is // repo-specific, so a global value would be wrong more often than useful. diff --git a/internal/store/store.go b/internal/store/store.go index 136f87d8e..b7a6f742b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -51,6 +51,15 @@ CREATE TABLE IF NOT EXISTS settings ( value TEXT NOT NULL, PRIMARY KEY (key, project_id) ); + +CREATE TABLE IF NOT EXISTS session_costs ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + transcript_id TEXT NOT NULL, + byte_offset INTEGER NOT NULL DEFAULT 0, + last_message_id TEXT NOT NULL DEFAULT '', + cost_usd REAL NOT NULL DEFAULT 0, + PRIMARY KEY (session_id, transcript_id) +); ` // busyTimeoutMS is how long a write waits on SQLite's lock before failing. diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 9d75aefff..7645a0107 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -16,6 +16,7 @@ import ( "sync" "github.com/omartelo/lich/internal/events" + "github.com/omartelo/lich/internal/pricing" "github.com/omartelo/lich/internal/providers" ) @@ -92,15 +93,21 @@ type session struct { } // Store is the persistence the terminal service depends on: the binary to spawn -// for a provider in a project (empty return spawns the provider's default), and -// where to record the provider session id a PTY reports through its session-start -// hook. The store implements both. +// for a provider in a project (empty return spawns the provider's default), +// where to record the provider session id a PTY reports through its +// session-start hook, and the running cost accounting behind the footer readout +// (CostReadout gates it — off, none of the rest is called). The store +// implements them all. type Store interface { ProviderBin(providerID, projectID string) string WorktreeSetup(projectID string) string SetProviderSession(sessionID, providerSessionID string) error ProviderSession(sessionID string) (string, error) SetSessionTitle(sessionID, title string) (bool, error) + CostReadout() bool + CostLedger(sessionID, transcriptID string) (int64, string, float64, error) + SaveCostLedger(sessionID, transcriptID string, offset int64, lastMessage string, cost float64) error + SessionCost(sessionID string) (float64, error) } // Service manages PTY-backed shell sessions keyed by session ID. @@ -116,6 +123,9 @@ type Service struct { // ws is the local WebSocket transport for terminal I/O (see transport.go); // nil when it failed to start, leaving /events and the RPC as the path. ws *transport + // prices resolves what a session's tokens cost. Nil disables the cost + // readout outright — the state a test that builds a bare Service is in. + prices rateSource } // New returns a ready-to-use terminal service that resolves the binary to spawn @@ -129,6 +139,7 @@ func New(store Store, env []string, hub *events.Hub) *Service { store: store, hub: hub, env: append(childEnv(env), "TERM=xterm-256color"), + prices: pricing.New(), } ws, err := newTransport( func(id string, data []byte) { diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index 4c92986d1..c486cdfdb 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -24,10 +24,34 @@ import ( // for tests that never spawn. Its write methods are no-ops — none of these tests // exercise the SessionStart or ai-title paths — while providerSession and its // error drive the context-usage read (usage_test.go). +// +// The cost side is a real (if tiny) implementation rather than a no-op: the +// ledger is what makes a second read resume instead of recount, so a stub that +// forgot it would let that contract pass untested. ledgers is a map so the +// value receivers can still write to it; a test that exercises cost builds one +// with newCostStore. type stubBins struct { bin, setup string providerSession string providerErr error + costOn bool + ledgers map[string]stubLedger +} + +// stubLedger mirrors one session_costs row. +type stubLedger struct { + offset int64 + lastMessage string + cost float64 +} + +// newCostStore is a stubBins with the cost readout on and an empty ledger. +func newCostStore(providerSession string) stubBins { + return stubBins{ + providerSession: providerSession, + costOn: true, + ledgers: make(map[string]stubLedger), + } } func (s stubBins) ProviderBin(_, _ string) string { return s.bin } @@ -38,6 +62,30 @@ func (s stubBins) ProviderSession(_ string) (string, error) { } func (s stubBins) SetSessionTitle(_, _ string) (bool, error) { return false, nil } +func (s stubBins) CostReadout() bool { return s.costOn } + +func (s stubBins) CostLedger(sessionID, transcriptID string) (int64, string, float64, error) { + ledger := s.ledgers[sessionID+"\x00"+transcriptID] + return ledger.offset, ledger.lastMessage, ledger.cost, nil +} + +func (s stubBins) SaveCostLedger( + sessionID, transcriptID string, offset int64, lastMessage string, cost float64, +) error { + s.ledgers[sessionID+"\x00"+transcriptID] = stubLedger{offset, lastMessage, cost} + return nil +} + +func (s stubBins) SessionCost(sessionID string) (float64, error) { + total := 0.0 + for key, ledger := range s.ledgers { + if strings.HasPrefix(key, sessionID+"\x00") { + total += ledger.cost + } + } + return total, nil +} + // TestChildEnvStripsAppImageVars proves the AppImage runtime variables that break // mise/asdf shims are dropped while the real user environment is passed through. func TestChildEnvStripsAppImageVars(t *testing.T) { diff --git a/internal/terminal/usage.go b/internal/terminal/usage.go index 5e6ea9a60..64bdbb647 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -12,13 +12,19 @@ const usageEventName = "session-usage" // context window the turn left occupied (0–100); Tokens is the raw input-side // count behind it, Window the model's context window, Model the model id, and // Effort the reasoning effort level ("" when the line records none). +// +// CostUSD is what the session has cost so far, and is omitted — not zeroed — +// whenever there is no number worth showing: the setting is off, or a model in +// the transcript has no known price. The readout is for API billing, so on a +// subscription the field never appears at all and the footer shows nothing. type usageEvent struct { - ID string `json:"id"` - Percent int `json:"percent"` - Tokens int `json:"tokens"` - Window int `json:"window"` - Model string `json:"model"` - Effort string `json:"effort"` + ID string `json:"id"` + Percent int `json:"percent"` + Tokens int `json:"tokens"` + Window int `json:"window"` + Model string `json:"model"` + Effort string `json:"effort"` + CostUSD *float64 `json:"costUsd,omitempty"` } // emitUsage reads the context-window usage of the provider conversation running @@ -54,12 +60,62 @@ func (s *Service) sessionUsage(id string) (usageEvent, bool) { if !ok { return usageEvent{}, false } - return usageEvent{ + event := usageEvent{ ID: id, Percent: u.percent, Tokens: u.tokens, Window: u.window, Model: u.model, Effort: u.effort, - }, true + } + if cost, ok := s.sessionCost(id, providerSessionID); ok { + event.CostUSD = &cost + } + return event, true +} + +// sessionCost is what session id has cost across every conversation it has run, +// or ok=false for "no number to show". That covers the flag being off (nothing +// is even read then), a transcript that cannot be reached, and a model no price +// table knows — see scanTranscriptCost for why an unpriced line stops the count +// instead of being skipped. +// +// The accounting is persisted per transcript, so it survives both a `/clear` +// (a new conversation under the same session, counted into its own row) and a +// restart of lich itself. +func (s *Service) sessionCost(id, providerSessionID string) (float64, bool) { + if s.prices == nil || !s.store.CostReadout() { + return 0, false + } + path, ok := claudeTranscriptPath(providerSessionID) + if !ok { + return 0, false + } + offset, lastMessage, cost, err := s.store.CostLedger(id, providerSessionID) + if err != nil { + slog.Warn("terminal: read cost ledger", "session", id, "err", err) + return 0, false + } + from := costLedger{offset: offset, lastMessage: lastMessage, cost: cost} + ledger, complete, ok := scanTranscriptCost(path, from, s.prices) + if !ok { + return 0, false + } + if ledger != from { + if err := s.store.SaveCostLedger( + id, providerSessionID, ledger.offset, ledger.lastMessage, ledger.cost, + ); err != nil { + slog.Warn("terminal: save cost ledger", "session", id, "err", err) + return 0, false + } + } + if !complete { + return 0, false + } + total, err := s.store.SessionCost(id) + if err != nil { + slog.Warn("terminal: read session cost", "session", id, "err", err) + return 0, false + } + return total, true } diff --git a/internal/terminal/usage_cost.go b/internal/terminal/usage_cost.go new file mode 100644 index 000000000..6977d9294 --- /dev/null +++ b/internal/terminal/usage_cost.go @@ -0,0 +1,138 @@ +package terminal + +import ( + "bufio" + "encoding/json" + "io" + "os" + + "github.com/omartelo/lich/internal/pricing" +) + +// rateSource prices a model's tokens. An interface so the cost scan can be +// tested against a fixed table instead of the shipped one — pricing.Table is +// the only implementation. +type rateSource interface { + Rate(model string) (pricing.Rate, bool) +} + +// costLedger is how far one transcript has been accounted for and what that +// stretch cost, mirroring the row the store keeps (see store.CostLedger). It +// travels through a scan: in as where to resume, out as where to resume next +// time. +type costLedger struct { + offset int64 + lastMessage string + cost float64 +} + +// costLine is the one transcript line the cost scan cares about: an assistant +// message with the four token counters a provider bills. Unlike the context +// read, a sidechain line counts — a Task sub-agent's tokens are billed to the +// same account as the turn that spawned it. +type costLine struct { + messageID string + model string + tokens pricing.Tokens +} + +// scanTranscriptCost adds up what a transcript has cost, resuming from where +// the last scan stopped so a turn re-reads only what was appended since. ok is +// false when the file cannot be read at all. +// +// complete reports that the scan reached the end of the file. It is false when +// a line names a model nothing can price yet: the scan stops there rather than +// skipping the line, because a total missing one turn is a wrong number, and a +// wrong number about money is worse than none. The lookup that missed schedules +// the price refresh that may unblock it, so the next turn resumes from the same +// line — this heals itself or stays absent, and never guesses. +func scanTranscriptCost(path string, from costLedger, rates rateSource) (costLedger, bool, bool) { + f, err := os.Open(path) + if err != nil { + return from, false, false + } + defer func() { _ = f.Close() }() + + info, err := f.Stat() + if err != nil { + return from, false, false + } + // A transcript shorter than what was already counted is not the file that + // was counted: it was replaced or truncated, so the row it backs is rebuilt + // from zero rather than continued into content that is no longer there. + if info.Size() < from.offset { + from = costLedger{} + } + if _, err := f.Seek(from.offset, io.SeekStart); err != nil { + return from, false, false + } + ledger, complete := accumulate(bufio.NewReader(f), from, rates) + return ledger, complete, true +} + +// accumulate walks complete lines from r, folding each priced assistant message +// into the ledger. A trailing line with no newline is left unconsumed: the +// provider is still writing it, and the next scan reads it whole. +func accumulate(r *bufio.Reader, ledger costLedger, rates rateSource) (costLedger, bool) { + for { + line, err := r.ReadBytes('\n') + if err != nil { + return ledger, true + } + entry, ok := parseCostLine(line) + // An id is what makes a repeat recognisable; a line carrying none is + // counted on its own merits rather than folded into the last message. + repeat := entry.messageID != "" && entry.messageID == ledger.lastMessage + if !ok || repeat { + // Not billable, or the same assistant message written across several + // lines — each repeats that message's cumulative usage, so counting + // the repeat would bill the turn twice. + ledger.offset += int64(len(line)) + continue + } + rate, priced := rates.Rate(entry.model) + if !priced { + return ledger, false + } + ledger.cost += rate.Cost(entry.tokens) + ledger.lastMessage = entry.messageID + ledger.offset += int64(len(line)) + } +} + +// parseCostLine pulls the billable content out of a transcript line. false for +// anything that is not an assistant message with tokens on it — a user turn, a +// malformed line, or one of the synthetic assistant entries a provider writes +// for its own errors, which report no tokens and so name no model to price. +func parseCostLine(line []byte) (costLine, bool) { + var entry struct { + Type string `json:"type"` + Message struct { + ID string `json:"id"` + Model string `json:"model"` + Usage *struct { + Input int `json:"input_tokens"` + Output int `json:"output_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + CacheCreate int `json:"cache_creation_input_tokens"` + } `json:"usage"` + } `json:"message"` + } + if err := json.Unmarshal(line, &entry); err != nil { + return costLine{}, false + } + if entry.Type != "assistant" || entry.Message.Usage == nil { + return costLine{}, false + } + u := entry.Message.Usage + tokens := pricing.Tokens{ + Input: u.Input, + Output: u.Output, + CacheRead: u.CacheRead, + CacheWrite: u.CacheCreate, + } + if tokens == (pricing.Tokens{}) { + return costLine{}, false + } + return costLine{messageID: entry.Message.ID, model: entry.Message.Model, tokens: tokens}, true +} diff --git a/internal/terminal/usage_cost_test.go b/internal/terminal/usage_cost_test.go new file mode 100644 index 000000000..7e6cd587a --- /dev/null +++ b/internal/terminal/usage_cost_test.go @@ -0,0 +1,343 @@ +// The suite reuses stubBins from terminal_test.go, which is Unix-tagged for its +// PTY spawns; this file carries the same tag so the package still builds on +// Windows. Nothing here is platform-specific. +//go:build !windows + +package terminal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/omartelo/lich/internal/events" + "github.com/omartelo/lich/internal/pricing" +) + +// fixedRates prices only what it was given, so a test states its own economy +// and an unpriced model is a deliberate case rather than a table lookup. +type fixedRates map[string]pricing.Rate + +func (f fixedRates) Rate(model string) (pricing.Rate, bool) { + rate, ok := f[model] + return rate, ok +} + +// testRate is round enough to read totals off by eye: 1 unit of anything is +// $0.001, except output at $0.01. +var testRate = fixedRates{ + modelOpus: {Input: 0.001, Output: 0.01, CacheRead: 0.001, CacheWrite: 0.001}, +} + +// costLine renders an assistant transcript line with an explicit message id and +// the four counters the bill is built from. +func costLineJSON(id, model string, input, output, cacheRead, cacheCreate int) string { + return `{"type":"assistant","message":{"id":"` + id + `","model":"` + model + + `","usage":{"input_tokens":` + itoa(input) + + `,"output_tokens":` + itoa(output) + + `,"cache_read_input_tokens":` + itoa(cacheRead) + + `,"cache_creation_input_tokens":` + itoa(cacheCreate) + `}}}` +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + digits := "" + for ; n > 0; n /= 10 { + digits = string(rune('0'+n%10)) + digits + } + return digits +} + +// writeFile plants a transcript at a throwaway path and returns it. +func writeFile(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "transcript.jsonl") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// TestCostSumsEveryAssistantLine is the traversal the readout needs and the +// context gauge does not: the whole file, not its tail, and output tokens +// included — they are the expensive half of a bill. +func TestCostSumsEveryAssistantLine(t *testing.T) { + path := writeFile(t, + costLineJSON("m1", modelOpus, 100, 10, 0, 0)+"\n"+ + `{"type":"user","message":{"content":"hi"}}`+"\n"+ + costLineJSON("m2", modelOpus, 200, 20, 0, 0)+"\n") + + ledger, complete, ok := scanTranscriptCost(path, costLedger{}, testRate) + + if !ok || !complete { + t.Fatalf("scan: ok=%v complete=%v, want both true", ok, complete) + } + want := 300*0.001 + 30*0.01 + if !nearly(ledger.cost, want) { + t.Errorf("cost = %v, want %v", ledger.cost, want) + } +} + +// TestCostResumesWhereItStopped proves the scan is incremental: it re-reads +// only what was appended. emitUsage runs on every non-idle hook, so recounting +// the file each time would make a long session's readout cost more than the +// turn it reports on. +func TestCostResumesWhereItStopped(t *testing.T) { + first := costLineJSON("m1", modelOpus, 100, 0, 0, 0) + "\n" + path := writeFile(t, first) + + ledger, _, _ := scanTranscriptCost(path, costLedger{}, testRate) + if ledger.offset != int64(len(first)) { + t.Fatalf("offset = %d, want %d (the whole first line)", ledger.offset, len(first)) + } + + appended := costLineJSON("m2", modelOpus, 100, 0, 0, 0) + "\n" + if err := os.WriteFile(path, []byte(first+appended), 0o644); err != nil { + t.Fatal(err) + } + next, _, _ := scanTranscriptCost(path, ledger, testRate) + + if !nearly(next.cost, 0.2) { + t.Errorf("cost = %v, want 0.2 — the resumed scan must add, not recount", next.cost) + } +} + +// TestAPartialLineIsLeftForTheNextScan pins the mid-write case: the provider +// appends while lich reads, so a tail with no newline is content that is not +// there yet. Counting it would bill a half-written number. +func TestAPartialLineIsLeftForTheNextScan(t *testing.T) { + whole := costLineJSON("m1", modelOpus, 100, 0, 0, 0) + "\n" + path := writeFile(t, whole+`{"type":"assistant","message":{"id":"m2"`) + + ledger, complete, ok := scanTranscriptCost(path, costLedger{}, testRate) + + if !ok || !complete { + t.Fatalf("scan: ok=%v complete=%v, want both true", ok, complete) + } + if ledger.offset != int64(len(whole)) { + t.Errorf("offset = %d, want %d — the partial line must stay unread", ledger.offset, len(whole)) + } + if !nearly(ledger.cost, 0.1) { + t.Errorf("cost = %v, want only the complete line's 0.1", ledger.cost) + } +} + +// TestOneMessageWrittenTwiceIsBilledOnce pins the dedup. A provider may write +// one assistant message across several lines, each repeating that message's +// cumulative usage; counting the repeat doubles the turn. +func TestOneMessageWrittenTwiceIsBilledOnce(t *testing.T) { + line := costLineJSON("m1", modelOpus, 100, 0, 0, 0) + path := writeFile(t, line+"\n"+line+"\n") + + ledger, _, _ := scanTranscriptCost(path, costLedger{}, testRate) + + if !nearly(ledger.cost, 0.1) { + t.Errorf("cost = %v, want 0.1 — the repeated message must be billed once", ledger.cost) + } +} + +// TestTheDedupSurvivesAScanBoundary is the same contract across two reads: the +// repeat can land in the next scan, so the last billed message id is part of +// what the ledger remembers. +func TestTheDedupSurvivesAScanBoundary(t *testing.T) { + line := costLineJSON("m1", modelOpus, 100, 0, 0, 0) + path := writeFile(t, line+"\n") + + ledger, _, _ := scanTranscriptCost(path, costLedger{}, testRate) + if err := os.WriteFile(path, []byte(line+"\n"+line+"\n"), 0o644); err != nil { + t.Fatal(err) + } + next, _, _ := scanTranscriptCost(path, ledger, testRate) + + if !nearly(next.cost, 0.1) { + t.Errorf("cost = %v, want 0.1 — the repeat crossed a scan and was billed again", next.cost) + } +} + +// TestAnUnpricedModelStopsTheCount is the money invariant. A line nothing can +// price is not skipped: skipping it would report a total that silently misses a +// turn. The scan stops there, the readout goes absent, and the same line is +// retried once a price for it lands. +func TestAnUnpricedModelStopsTheCount(t *testing.T) { + path := writeFile(t, + costLineJSON("m1", modelOpus, 100, 0, 0, 0)+"\n"+ + costLineJSON("m2", "model-from-the-future", 900, 0, 0, 0)+"\n"+ + costLineJSON("m3", modelOpus, 100, 0, 0, 0)+"\n") + + ledger, complete, ok := scanTranscriptCost(path, costLedger{}, testRate) + + if !ok { + t.Fatal("scan: want ok") + } + if complete { + t.Error("complete = true, want false — an unpriced line must withhold the total") + } + if !nearly(ledger.cost, 0.1) { + t.Errorf("cost = %v, want only the priced line before the stop", ledger.cost) + } + + // The price lands (the miss scheduled the refresh) and the same line is + // picked up from where the scan stopped. + taught := fixedRates{modelOpus: testRate[modelOpus], "model-from-the-future": {Input: 0.001}} + next, complete, _ := scanTranscriptCost(path, ledger, taught) + if !complete { + t.Error("complete = false after the price landed, want true") + } + if !nearly(next.cost, 0.1+0.9+0.1) { + t.Errorf("cost = %v, want every line counted once the model was priced", next.cost) + } +} + +// TestASyntheticLineIsNotAModelToPrice proves the provider's own error entries +// — no tokens, and a placeholder where the model id goes — neither cost +// anything nor block the count behind a model that will never have a price. +func TestASyntheticLineIsNotAModelToPrice(t *testing.T) { + path := writeFile(t, + costLineJSON("m1", "", 0, 0, 0, 0)+"\n"+ + costLineJSON("m2", modelOpus, 100, 0, 0, 0)+"\n") + + ledger, complete, ok := scanTranscriptCost(path, costLedger{}, testRate) + + if !ok || !complete { + t.Fatalf("scan: ok=%v complete=%v, want both true", ok, complete) + } + if !nearly(ledger.cost, 0.1) { + t.Errorf("cost = %v, want 0.1 — the synthetic line must be passed over", ledger.cost) + } +} + +// TestASubAgentsTokensAreBilled is where cost parts ways with the context +// gauge: a Task sub-agent's sidechain lines are excluded from the window the +// user sees, but they are billed to the same account. +func TestASubAgentsTokensAreBilled(t *testing.T) { + sidechain := `{"type":"assistant","isSidechain":true,"message":{"id":"m2","model":"` + modelOpus + + `","usage":{"input_tokens":100,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}` + path := writeFile(t, costLineJSON("m1", modelOpus, 100, 0, 0, 0)+"\n"+sidechain+"\n") + + ledger, _, _ := scanTranscriptCost(path, costLedger{}, testRate) + + if !nearly(ledger.cost, 0.2) { + t.Errorf("cost = %v, want 0.2 — a sub-agent's tokens are billed too", ledger.cost) + } +} + +// TestAReplacedTranscriptIsRecounted pins the file-shrank case: a transcript +// shorter than what was already counted is not the file that was counted, so +// its row is rebuilt rather than resumed into content that is gone. +func TestAReplacedTranscriptIsRecounted(t *testing.T) { + path := writeFile(t, costLineJSON("m1", modelOpus, 500, 0, 0, 0)+"\n") + ledger, _, _ := scanTranscriptCost(path, costLedger{}, testRate) + + shorter := costLineJSON("m9", modelOpus, 10, 0, 0, 0) + "\n" + if err := os.WriteFile(path, []byte(shorter), 0o644); err != nil { + t.Fatal(err) + } + next, _, ok := scanTranscriptCost(path, ledger, testRate) + + if !ok { + t.Fatal("scan: want ok") + } + if !nearly(next.cost, 0.01) { + t.Errorf("cost = %v, want 0.01 — the replaced file must be counted from zero", next.cost) + } +} + +// TestAnUnreadableTranscriptIsAMiss keeps the readout on the same "degrade, +// never break" contract as the context gauge. +func TestAnUnreadableTranscriptIsAMiss(t *testing.T) { + if _, _, ok := scanTranscriptCost(filepath.Join(t.TempDir(), "nope.jsonl"), costLedger{}, testRate); ok { + t.Error("scan: want a miss for a transcript that does not exist") + } +} + +// TestSessionCostRidesOnTheUsageEvent proves the wiring end to end: with the +// setting on, the event carries what the session has cost. +func TestSessionCostRidesOnTheUsageEvent(t *testing.T) { + writeTranscript(t, "uuid-cost", costLineJSON("m1", modelOpus, 100, 10, 0, 0)+"\n") + svc := New(newCostStore("uuid-cost"), nil, events.New()) + svc.prices = testRate + + got, ok := svc.sessionUsage("s1") + + if !ok { + t.Fatal("sessionUsage: want ok") + } + if got.CostUSD == nil { + t.Fatal("CostUSD is absent, want the session's cost") + } + if !nearly(*got.CostUSD, 0.1+0.1) { + t.Errorf("CostUSD = %v, want 0.2", *got.CostUSD) + } +} + +// TestTheCostIsAbsentWhenTheFlagIsOff is the feature's premise: on a +// subscription the number is noise, so it is not reported at all — not zero, +// not stale. The context readout beside it is unaffected. +func TestTheCostIsAbsentWhenTheFlagIsOff(t *testing.T) { + writeTranscript(t, "uuid-cost", costLineJSON("m1", modelOpus, 100, 10, 0, 0)+"\n") + svc := New(stubBins{providerSession: "uuid-cost"}, nil, events.New()) + svc.prices = testRate + + got, ok := svc.sessionUsage("s1") + + if !ok { + t.Fatal("sessionUsage: want ok — the context readout still reports") + } + if got.CostUSD != nil { + t.Errorf("CostUSD = %v, want absent while the setting is off", *got.CostUSD) + } +} + +// TestTheCostIsAbsentWhileAModelIsUnpriced carries the scan's invariant out to +// the event: no number is shown until every counted turn has a price. +func TestTheCostIsAbsentWhileAModelIsUnpriced(t *testing.T) { + writeTranscript(t, "uuid-cost", costLineJSON("m1", "model-from-the-future", 100, 0, 0, 0)+"\n") + svc := New(newCostStore("uuid-cost"), nil, events.New()) + svc.prices = testRate + + got, ok := svc.sessionUsage("s1") + + if !ok { + t.Fatal("sessionUsage: want ok") + } + if got.CostUSD != nil { + t.Errorf("CostUSD = %v, want absent while a model has no price", *got.CostUSD) + } +} + +// TestCostAccumulatesAcrossConversations is the `/clear` case: a new +// conversation under the same session gets its own transcript, and the session +// keeps what the previous one cost. +func TestCostAccumulatesAcrossConversations(t *testing.T) { + store := newCostStore("uuid-first") + writeTranscript(t, "uuid-first", costLineJSON("m1", modelOpus, 100, 0, 0, 0)+"\n") + svc := New(store, nil, events.New()) + svc.prices = testRate + if _, ok := svc.sessionUsage("s1"); !ok { + t.Fatal("sessionUsage: want ok on the first conversation") + } + + // /clear: same lich session, a fresh provider conversation and transcript. + writeTranscript(t, "uuid-second", costLineJSON("m2", modelOpus, 300, 0, 0, 0)+"\n") + store.providerSession = "uuid-second" + svc = New(store, nil, events.New()) + svc.prices = testRate + + got, ok := svc.sessionUsage("s1") + + if !ok { + t.Fatal("sessionUsage: want ok after the clear") + } + if got.CostUSD == nil || !nearly(*got.CostUSD, 0.4) { + t.Errorf("CostUSD = %v, want 0.4 — the cleared conversation still counts", got.CostUSD) + } +} + +// nearly compares two dollar amounts built from float multiplications, where +// the last bits of a sum are not worth asserting on. +func nearly(got, want float64) bool { + diff := got - want + return diff < 1e-9 && diff > -1e-9 +}