From c66438451b068357651794cf2c75b42609610229 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:08:26 +0000 Subject: [PATCH] feat: show what a session cost, behind a setting, for API billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer knew the model and how full the context window was, but never what the session cost. This adds that readout beside the context ring — off by default, and absent rather than zeroed when it is off, because on a subscription plan the number is noise and nobody on one should have to read a running total that does not apply to them. The setting lives in the backend rather than the page's localStorage: it gates the work, not just the paint. Off, no transcript is summed and no price is ever fetched. Three pieces sit behind it: - internal/pricing bills token counts against a table baked into the binary, which refreshes itself from LiteLLM's published one the first time a session runs a model this build never heard of, and caches that under the config dir. Keyed by model id and nothing else, so a second provider's reader prices off the same table. - The transcript read gains output_tokens and a second traversal. The context gauge answers "how full is the window now" from the tail; cost is a sum over every assistant line, sub-agents included — they are billed to the same account — resumed from a stored byte offset so a turn re-reads only what was appended. - session_costs keeps one ledger per (session, transcript), so a /clear starts a new conversation without losing what the old one cost, the total survives a restart, and a session parked with its worktree keeps it across the reopen. A model no table can price stops the count at that line instead of skipping it, and the readout goes absent until a price for it lands: a total that silently misses a turn is worse than no total, and pricing an unknown model at zero is the one answer this must never give. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NW5SGwooH8xCCvnrc3PhZY --- CHANGELOG.md | 15 + CLAUDE.md | 7 + frontend/src/components/FooterBar.tsx | 30 +- .../settings/ProviderBinSettings.tsx | 38 +- frontend/src/lib/cost-readout-store.test.ts | 93 +++++ frontend/src/lib/cost-readout-store.ts | 72 ++++ frontend/src/lib/session/session-cost.test.ts | 31 ++ frontend/src/lib/session/session-cost.ts | 20 + frontend/src/lib/session/session-events.ts | 19 +- .../lib/session/session-usage-store.test.ts | 45 +++ .../src/lib/session/session-usage-store.ts | 6 +- frontend/src/lib/use-cost-readout.ts | 9 + internal/pricing/prices.json | 146 ++++++++ internal/pricing/pricing.go | 298 +++++++++++++++ internal/pricing/pricing_test.go | 243 +++++++++++++ internal/store/cost.go | 119 ++++++ internal/store/cost_test.go | 194 ++++++++++ internal/store/mutations.go | 10 + internal/store/settings.go | 17 + internal/store/store.go | 9 + internal/terminal/terminal.go | 17 +- internal/terminal/terminal_test.go | 48 +++ internal/terminal/usage.go | 72 +++- internal/terminal/usage_cost.go | 138 +++++++ internal/terminal/usage_cost_test.go | 343 ++++++++++++++++++ 25 files changed, 2011 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/cost-readout-store.test.ts create mode 100644 frontend/src/lib/cost-readout-store.ts create mode 100644 frontend/src/lib/session/session-cost.test.ts create mode 100644 frontend/src/lib/session/session-cost.ts create mode 100644 frontend/src/lib/use-cost-readout.ts create mode 100644 internal/pricing/prices.json create mode 100644 internal/pricing/pricing.go create mode 100644 internal/pricing/pricing_test.go create mode 100644 internal/store/cost.go create mode 100644 internal/store/cost_test.go create mode 100644 internal/terminal/usage_cost.go create mode 100644 internal/terminal/usage_cost_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e28b6274..082c1298e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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 - **Settings › Font lists your installed fonts on Windows.** The picker read the diff --git a/CLAUDE.md b/CLAUDE.md index baaa51950..31de2146e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,13 @@ nobody knows it and that the call site never shows. The mechanism and the histor account's token in `GH_TOKEN` for every gh call lich makes for that project. A push still rides the remote's ssh key and signs with the global `user.email`, so a PR can be *read* by one account and its commits *land* under another, with no error anywhere. Per-worktree `core.sshCommand` + `user.email` is the upgrade path. +- **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 94a47e0d8..4bd171608 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 c24d03c3d..89fd530f9 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -23,10 +23,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 } @@ -37,6 +61,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 +}