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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Auto-collect token usage from **20+ AI coding tools**, aggregate it locally, and
You can — that is the honest alternative, and for a single tool it is enough. TokenTracker earns its place once you use more than one:

- **One number instead of six tabs.** Claude, Codex, Cursor, Gemini and Copilot each bill in their own dashboard, on their own reset schedule, in their own units. Nobody adds them up for you.
- **Subscriptions hide the number entirely.** A flat monthly plan shows you a quota bar, not what your usage would have cost. TokenTracker prices every token against public model rates, so you can see whether the plan is a bargain or a subsidy you have outgrown.
- **Subscriptions hide the number entirely.** A flat monthly plan shows you a quota bar, not what your usage would have cost. TokenTracker prices every token against public model rates, and you can enter what you pay to see the two side by side. The figure is list-price-equivalent for the window on screen, not a bill you would have paid — and when some usage cannot be priced it says so rather than quietly reporting a smaller number.
- **Per-project and per-model, not just per-account.** Billing pages answer "what do I owe this month". This answers "which repo, which model, and which hour" — the resolution you need to actually change something.
- **Quota chips before you hit the wall.** Live plan-limit usage sits on each provider's card, so a 5-hour window running out is something you see rather than something you discover.

Expand Down
7 changes: 7 additions & 0 deletions dashboard/src/content/copy.csv
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ dashboard.projects.limit_top_10,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top
dashboard.projects.empty,ui,ProjectUsagePanel,ProjectUsagePanel,empty,No public repositories,,active
dashboard.projects.unattributed,ui,ProjectUsagePanel,ProjectUsagePanel,unattributed,Not counted per repo:,Prefix for the list of sources with usage but no project attribution,active
dashboard.projects.unpriced,ui,ProjectUsagePanel,ProjectUsagePanel,unpriced,no model recorded,Shown instead of a cost when every row for this repo predates per-model project attribution,active
dashboard.plan_value.title,ui,PlanValueCard,PlanValueCard,title,Plan vs list price,,active
dashboard.plan_value.empty,ui,PlanValueCard,PlanValueCard,empty,Enter what you pay for a plan to compare it against list price.,Shown when no provider has a plan price set,active
dashboard.plan_value.headline,ui,PlanValueCard,PlanValueCard,headline,{{plan}}/mo of plans · {{list}} of usage at API list price,Deliberately neutral: no "saved" or "wasted",active
dashboard.plan_value.row,ui,PlanValueCard,PlanValueCard,row,{{plan}}/mo · {{list}} at list price,Per-provider line,active
dashboard.plan_value.floor_note,ui,PlanValueCard,PlanValueCard,floor_note,At least this much — some usage could not be priced ({{models}}).,Shown when any model is unpriced or fuzzy-matched,active
dashboard.plan_value.disclaimer,ui,PlanValueCard,PlanValueCard,disclaimer,List-price-equivalent for this window — not a bill you would have paid.,Guards against reading the number as a counterfactual,active
dashboard.plan_value.input_aria,ui,PlanValueCard,PlanValueCard,input_aria,Monthly plan price for {{source}},,active
dashboard.widgets.title,dashboard,DashboardPage,WidgetOnboardingCard,title,Desktop Widgets,,active
dashboard.widgets.hint,dashboard,DashboardPage,WidgetOnboardingCard,hint,"Right-click your desktop → Edit Widgets → search ""TokenTracker""",,active
dashboard.widgets.dismiss_aria,dashboard,DashboardPage,WidgetOnboardingCard,dismiss_aria,Dismiss widget intro,,active
Expand Down
73 changes: 73 additions & 0 deletions dashboard/src/hooks/use-plan-prices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PLAN_PRICES_STORAGE_KEY, usePlanPrices } from "./use-plan-prices";

describe("usePlanPrices", () => {
beforeEach(() => {
window.localStorage.clear();
});

it("starts empty when nothing is stored", () => {
const { result } = renderHook(() => usePlanPrices());
expect(result.current.planPrices).toEqual({});
});

it("round-trips a price through storage", () => {
const { result } = renderHook(() => usePlanPrices());
act(() => result.current.setPlanPrice("claude", 20));
expect(result.current.planPrices).toEqual({ claude: 20 });
expect(JSON.parse(window.localStorage.getItem(PLAN_PRICES_STORAGE_KEY)!)).toEqual({
claude: 20,
});
});

it("clearing a price REMOVES it rather than storing zero", () => {
// Absent is not zero. A stored 0 would make the card compare usage against
// a $0 plan, which is infinitely over by construction.
window.localStorage.setItem(PLAN_PRICES_STORAGE_KEY, JSON.stringify({ claude: 20 }));
const { result } = renderHook(() => usePlanPrices());
act(() => result.current.setPlanPrice("claude", null));
expect(result.current.planPrices).toEqual({});
expect(JSON.parse(window.localStorage.getItem(PLAN_PRICES_STORAGE_KEY)!)).toEqual({});
});

it("drops stored values that are not positive numbers", () => {
window.localStorage.setItem(
PLAN_PRICES_STORAGE_KEY,
JSON.stringify({ claude: 20, codex: 0, kimi: "abc", zed: -5, goose: null }),
);
const { result } = renderHook(() => usePlanPrices());
expect(result.current.planPrices).toEqual({ claude: 20 });
});

it("survives corrupt storage instead of throwing", () => {
window.localStorage.setItem(PLAN_PRICES_STORAGE_KEY, "{not json");
const { result } = renderHook(() => usePlanPrices());
expect(result.current.planPrices).toEqual({});
});

it("survives storage that is not an object", () => {
window.localStorage.setItem(PLAN_PRICES_STORAGE_KEY, JSON.stringify([1, 2, 3]));
const { result } = renderHook(() => usePlanPrices());
expect(result.current.planPrices).toEqual({});
});

it("keeps the value in memory when storage refuses the write", () => {
// Private-mode or quota-exceeded. Silently reverting under the user is
// worse than a preference that does not persist.
const { result } = renderHook(() => usePlanPrices());
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
act(() => result.current.setPlanPrice("claude", 20));
expect(result.current.planPrices).toEqual({ claude: 20 });
setItem.mockRestore();
});

it("normalises the provider key so CLAUDE and claude are one entry", () => {
const { result } = renderHook(() => usePlanPrices());
act(() => result.current.setPlanPrice("CLAUDE", 20));
act(() => result.current.setPlanPrice("claude", 25));
expect(result.current.planPrices).toEqual({ claude: 25 });
});
});
67 changes: 67 additions & 0 deletions dashboard/src/hooks/use-plan-prices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from "react";

// What the user pays each provider, per month, entered by them.
//
// Stored in localStorage like every other preference here and NEVER sent
// anywhere — there is no plan catalogue to maintain and go stale, and a plan
// price is exactly the kind of thing this product promises not to transmit.
//
// The value is a plain map so a provider the user has not priced is simply
// absent. Absent is not zero: treating it as zero would make every unpriced
// provider look infinitely over its plan.

const STORAGE_KEY = "tokentracker.plan_prices.v1";

export type PlanPrices = Record<string, number>;

function readStored(): PlanPrices {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: PlanPrices = {};
for (const [source, value] of Object.entries(parsed)) {
const price = Number(value);
// Anything that is not a positive finite number is dropped rather than
// coerced. A stored "0" or "abc" must not become a plan the card compares
// against.
if (Number.isFinite(price) && price > 0) out[String(source).toLowerCase()] = price;
}
return out;
} catch {
// Corrupt or unreadable storage is the same as none set.
return {};
}
}

export function usePlanPrices() {
const [prices, setPrices] = useState<PlanPrices>({});

useEffect(() => {
setPrices(readStored());
}, []);

const setPlanPrice = useCallback((source: string, price: number | null) => {
setPrices((current) => {
const key = String(source || "").toLowerCase();
if (!key) return current;
const next = { ...current };
const value = Number(price);
if (Number.isFinite(value) && value > 0) next[key] = value;
else delete next[key];
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Storage full or blocked — keep the in-memory value so the card still
// works this session rather than silently reverting under the user.
}
return next;
});
}, []);

return { planPrices: prices, setPlanPrice, PLAN_PRICES_STORAGE_KEY: STORAGE_KEY };
}

export { STORAGE_KEY as PLAN_PRICES_STORAGE_KEY };
156 changes: 156 additions & 0 deletions dashboard/src/lib/plan-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { computePlanValue, formatUsd, rollUpPlanValues } from "./plan-value";

const source = (over: Record<string, unknown> = {}) => ({
source: "claude",
totals: { total_cost_usd: "47.000000" },
models: [{ model: "claude-sonnet-5", pricing_tier: "litellm:exact" }],
...over,
});

describe("computePlanValue", () => {
it("compares this window's list price against what the user pays", () => {
const value = computePlanValue(source(), 20)!;
expect(value.planPriceUsd).toBe(20);
expect(value.listPriceUsd).toBe(47);
expect(value.direction).toBe("above");
expect(value.ratio).toBeCloseTo(2.35);
});

it("reads the same way when usage is BELOW the plan price", () => {
// Under-usage is a downgrade signal and is exactly as useful as the other
// direction, so nothing here treats it as the bad case.
const value = computePlanValue(source({ totals: { total_cost_usd: "3.00" } }), 20)!;
expect(value.direction).toBe("below");
expect(value.listPriceUsd).toBe(3);
expect(value.confidence).toBe("exact");
});

it("returns null with no plan price — absent is not zero", () => {
// A card with no number beats a card with a wrong one, and treating an
// unentered price as $0 would make every provider look infinitely over.
expect(computePlanValue(source(), null)).toBeNull();
expect(computePlanValue(source(), undefined)).toBeNull();
expect(computePlanValue(source(), 0)).toBeNull();
expect(computePlanValue(source(), -5)).toBeNull();
expect(computePlanValue(source(), Number.NaN)).toBeNull();
});

it("marks the figure a FLOOR when some usage could not be priced", () => {
// The caveats already shipped in #92 exist for exactly this. An unpriced
// model means the real list price is higher than the number shown.
const value = computePlanValue(
source({
models: [
{ model: "claude-sonnet-5", pricing_tier: "litellm:exact" },
{ model: "mystery-model", pricing_tier: "miss" },
],
}),
20,
)!;
expect(value.confidence).toBe("floor");
expect(value.unpricedModels).toEqual(["mystery-model"]);
});

it("treats a fuzzy match as a floor too, and names it separately", () => {
// A substring match can be wrong in either direction. It is still not a
// figure anyone should read as exact.
const value = computePlanValue(
source({ models: [{ model: "some-model-v3", pricing_tier: "litellm:fuzzy" }] }),
20,
)!;
expect(value.confidence).toBe("floor");
expect(value.fuzzyModels).toEqual(["some-model-v3"]);
expect(value.unpricedModels).toEqual([]);
});

it("counts the `unattributed` tier as unpriced", () => {
// The tier added in #94 for "we recorded this before we recorded models".
const value = computePlanValue(
source({ models: [{ model: "unknown", pricing_tier: "unattributed" }] }),
20,
)!;
expect(value.confidence).toBe("floor");
expect(value.unpricedModels).toEqual(["unknown"]);
});

it("is exact when every model priced exactly", () => {
const value = computePlanValue(
source({
models: [
{ model: "a", pricing_tier: "curated:exact" },
{ model: "b", pricing_tier: "litellm:exact-dot" },
{ model: "c", pricing_tier: "curated:alias" },
],
}),
20,
)!;
expect(value.confidence).toBe("exact");
});

it("does not choke on a source with no models or no totals", () => {
const value = computePlanValue({ source: "zed" }, 10)!;
expect(value.listPriceUsd).toBe(0);
expect(value.direction).toBe("below");
expect(value.confidence).toBe("exact");
});

it("deduplicates and sorts the named models", () => {
const value = computePlanValue(
source({
models: [
{ model: "zeta", pricing_tier: "miss" },
{ model: "alpha", pricing_tier: "miss" },
{ model: "zeta", pricing_tier: "empty" },
],
}),
20,
)!;
expect(value.unpricedModels).toEqual(["alpha", "zeta"]);
});
});

describe("rollUpPlanValues", () => {
const claude = computePlanValue(source(), 20)!;
const codex = computePlanValue(
source({ source: "codex", totals: { total_cost_usd: "5.00" } }),
25,
)!;

it("sums both sides across providers", () => {
const rollup = rollUpPlanValues([claude, codex])!;
expect(rollup.planPriceUsd).toBe(45);
expect(rollup.listPriceUsd).toBe(52);
expect(rollup.direction).toBe("above");
expect(rollup.providerCount).toBe(2);
});

it("one floor anywhere makes the whole headline a floor", () => {
// Averaging confidence would let a well-priced provider vouch for a
// badly-priced one.
const shaky = computePlanValue(
source({ source: "kimi", models: [{ model: "x", pricing_tier: "miss" }] }),
10,
)!;
expect(rollUpPlanValues([claude, shaky])!.confidence).toBe("floor");
expect(rollUpPlanValues([claude, codex])!.confidence).toBe("exact");
});

it("returns null when no provider has a plan price", () => {
expect(rollUpPlanValues([])).toBeNull();
});

it("reports `below` for the roll-up too, without treating it as failure", () => {
const cheap = computePlanValue(source({ totals: { total_cost_usd: "1.00" } }), 20)!;
expect(rollUpPlanValues([cheap])!.direction).toBe("below");
});
});

describe("formatUsd", () => {
it("keeps small numbers from reading as zero", () => {
expect(formatUsd(0.0004)).toBe("$0.0004");
expect(formatUsd(3)).toBe("$3.00");
expect(formatUsd(0)).toBe("$0.00");
expect(formatUsd(Number.NaN)).toBe("$0.00");
});
});
Loading