{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cost-meter","type":"registry:ui","title":"Cost Meter","description":"Live spend per message and per conversation — cached input priced apart from fresh, reasoning tokens shown without double-counting, and a visible ~ when a provider did not report enough to be sure.","author":"Scrim UI (https://scrimui.dev)","categories":["model-settings"],"docs":"https://scrimui.dev/components/cost-meter","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/cost-meter/cost-meter.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Live spend, per message and per conversation.\n *\n * A number on screen that says \"$0.14\" is a claim, and a wrong claim about\n * money is a support ticket. Almost every meter shipped in an AI product gets\n * this wrong in one of four ways, and each one has an answer here:\n *\n *  1. **Cached input is not input.** The same prompt prefix is re-read on\n *     every turn, and providers charge a tenth or less for a cache read.\n *     `inputTokens` is the total billed for input, cache reads included, so\n *     multiplying it by the fresh rate over-bills a long conversation by an\n *     order of magnitude. The cached share is subtracted and priced apart.\n *  2. **Reasoning tokens are billed and invisible.** They are already inside\n *     `outputTokens`; adding `reasoningTokens` on top double-counts. They are\n *     broken out here only so the meter can *show* what was paid for and\n *     never read — which is usually the line that explains the bill.\n *  3. **Usage can be `undefined`.** Not every provider reports every field,\n *     and a stream that fails mid-turn reports none of them. `undefined` is\n *     not zero. Rendering \"$0.00\" for \"we do not know\" is the one outcome\n *     worse than rendering nothing, so an incomplete usage record produces\n *     `exact: false` and the meter shows a visible `~`.\n *  4. **Sub-cent precision.** A meter that reads $0.00 for six turns and then\n *     jumps to $0.01 looks broken. Four decimals under a cent, two above it,\n *     where the extra digits stop being information.\n *\n * The prices live with the caller, not in here. A rate table baked into a\n * component is a rate table that goes stale in someone else's node_modules.\n */\n\n/* ------------------------------------------------------------------ */\n/* Arithmetic                                                          */\n/* ------------------------------------------------------------------ */\n\nexport type Usage = {\n  inputTokens?: number;\n  /** The share of `inputTokens` that was served from cache, not added to it. */\n  cachedInputTokens?: number;\n  outputTokens?: number;\n  /** Already counted inside `outputTokens`. Broken out to display, never to add. */\n  reasoningTokens?: number;\n};\n\n/** USD per million tokens, as every provider quotes them. */\nexport type ModelPrice = {\n  input: number;\n  cachedInput: number;\n  output: number;\n};\n\nexport type Cost = {\n  usd: number;\n  /** False when a field the price depends on was missing. */\n  exact: boolean;\n};\n\n/** Sums two usage records, treating a missing field as missing, not as zero. */\nexport function addUsage(a: Usage, b: Usage): Usage {\n  const add = (x?: number, y?: number) =>\n    x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0);\n  return {\n    inputTokens: add(a.inputTokens, b.inputTokens),\n    cachedInputTokens: add(a.cachedInputTokens, b.cachedInputTokens),\n    outputTokens: add(a.outputTokens, b.outputTokens),\n    reasoningTokens: add(a.reasoningTokens, b.reasoningTokens),\n  };\n}\n\nexport function costOf(usage: Usage, price: ModelPrice): Cost {\n  const cached = usage.cachedInputTokens ?? 0;\n  /* Clamped at zero because a provider that reports the two independently\n     can, briefly, disagree with itself mid-stream. */\n  const fresh = Math.max(0, (usage.inputTokens ?? 0) - cached);\n\n  const usd =\n    (fresh / 1_000_000) * price.input +\n    (cached / 1_000_000) * price.cachedInput +\n    ((usage.outputTokens ?? 0) / 1_000_000) * price.output;\n\n  return { usd, exact: usage.inputTokens !== undefined && usage.outputTokens !== undefined };\n}\n\nexport function formatCost({ usd, exact }: Cost): string {\n  const value = usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2);\n  return `${exact ? \"\" : \"~\"}$${value}`;\n}\n\nexport function formatTokens(n: number): string {\n  if (n < 1000) return String(n);\n  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;\n  return `${(n / 1_000_000).toFixed(1)}M`;\n}\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction ChevronIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"m6 9 6 6 6-6\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* CostMeter                                                           */\n/* ------------------------------------------------------------------ */\n\nexport type CostMeterProps = {\n  /** Shown as attribution — the rates below are only true for one model. */\n  model: string;\n  price: ModelPrice;\n  /** This turn. */\n  usage: Usage;\n  /** The conversation so far, including this turn. Omit for a per-message meter. */\n  total?: Usage;\n  /** A cap to render progress against, in USD. */\n  budgetUsd?: number;\n  /**\n   * True while the turn is still generating. Output tokens are still climbing,\n   * so the figure is a running subtotal rather than a final one — and it is\n   * labelled as such instead of being animated as though it were settled.\n   */\n  streaming?: boolean;\n  defaultOpen?: boolean;\n  className?: string;\n};\n\nexport function CostMeter({\n  model,\n  price,\n  usage,\n  total,\n  budgetUsd,\n  streaming = false,\n  defaultOpen = false,\n  className = \"\",\n}: CostMeterProps) {\n  const [open, setOpen] = React.useState(defaultOpen);\n\n  const turn = costOf(usage, price);\n  const running = total ? costOf(total, price) : undefined;\n  const headline = running ?? turn;\n\n  const cached = usage.cachedInputTokens ?? 0;\n  const fresh = Math.max(0, (usage.inputTokens ?? 0) - cached);\n  const reasoning = usage.reasoningTokens ?? 0;\n\n  /* Clamped at 1: a bar that overflows its track is a rendering bug, and a\n     bar pinned at full next to a number that keeps climbing is not. */\n  const spent = budgetUsd ? Math.min(1, headline.usd / budgetUsd) : 0;\n  const overBudget = budgetUsd !== undefined && headline.usd > budgetUsd;\n\n  return (\n    <div className={`rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}>\n      <div className=\"flex items-center gap-3 px-3.5 py-2.5\">\n        <span className=\"min-w-0 truncate font-mono text-[11px] text-zinc-500 dark:text-zinc-400\">\n          {model}\n        </span>\n\n        <span className=\"ml-auto flex shrink-0 items-baseline gap-2.5\">\n          <span className=\"tabular-nums text-[11px] text-zinc-500 dark:text-zinc-400\">\n            {usage.inputTokens === undefined ? \"—\" : formatTokens(usage.inputTokens)} in ·{\" \"}\n            {usage.outputTokens === undefined ? \"—\" : formatTokens(usage.outputTokens)} out\n          </span>\n          <span\n            /* The figure changes while the reader is looking at it, so it is\n               announced politely rather than on every token. */\n            aria-live=\"polite\"\n            className={`tabular-nums text-sm font-medium ${\n              overBudget ? \"text-red-600 dark:text-red-400\" : \"text-zinc-900 dark:text-zinc-100\"\n            }`}\n            title={headline.exact ? undefined : \"Approximate — the provider did not report every field.\"}\n          >\n            {formatCost(headline)}\n          </span>\n        </span>\n\n        <button\n          type=\"button\"\n          onClick={() => setOpen((v) => !v)}\n          aria-expanded={open}\n          aria-label={open ? \"Hide the breakdown\" : \"Show the breakdown\"}\n          className=\"-mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300\"\n        >\n          <ChevronIcon className={open ? \"rotate-180\" : \"\"} />\n        </button>\n      </div>\n\n      {budgetUsd !== undefined && (\n        <div className=\"px-3.5 pb-2.5\">\n          <div className=\"h-1 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800\">\n            <div\n              className={`h-full rounded-full transition-[width] duration-500 ${\n                overBudget ? \"bg-red-500\" : spent > 0.8 ? \"bg-amber-500\" : \"bg-zinc-400 dark:bg-zinc-500\"\n              }`}\n              style={{ width: `${spent * 100}%` }}\n            />\n          </div>\n          <p className=\"mt-1 text-[11px] text-zinc-400 dark:text-zinc-500\">\n            {overBudget\n              ? `Over the ${formatCost({ usd: budgetUsd, exact: true })} budget for this conversation.`\n              : `of ${formatCost({ usd: budgetUsd, exact: true })} budgeted`}\n          </p>\n        </div>\n      )}\n\n      {open && (\n        <dl className=\"space-y-1.5 border-t border-zinc-100 px-3.5 py-3 text-[11px] dark:border-zinc-800\">\n          <Row\n            label=\"Fresh input\"\n            value={usage.inputTokens === undefined ? \"not reported\" : `${formatTokens(fresh)} · ${money((fresh / 1e6) * price.input)}`}\n            hint={`$${price.input}/M`}\n          />\n          {/* Shown even at zero, because \"no cache hits on this turn\" is\n              information — it is the difference between a conversation that\n              is getting cheaper and one that is not. */}\n          <Row\n            label=\"Cached input\"\n            value={`${formatTokens(cached)} · ${money((cached / 1e6) * price.cachedInput)}`}\n            hint={`$${price.cachedInput}/M`}\n          />\n          <Row\n            label=\"Output\"\n            value={usage.outputTokens === undefined ? \"not reported\" : `${formatTokens(usage.outputTokens)} · ${money(((usage.outputTokens ?? 0) / 1e6) * price.output)}`}\n            hint={`$${price.output}/M`}\n          />\n          {reasoning > 0 && (\n            <Row\n              label=\"…of which reasoning\"\n              value={formatTokens(reasoning)}\n              hint=\"billed, not shown\"\n              muted\n            />\n          )}\n\n          {total && (\n            <div className=\"mt-2.5 flex items-baseline justify-between border-t border-zinc-100 pt-2.5 dark:border-zinc-800\">\n              <dt className=\"text-zinc-500 dark:text-zinc-400\">This turn</dt>\n              <dd className=\"tabular-nums font-medium text-zinc-700 dark:text-zinc-200\">\n                {formatCost(turn)}\n              </dd>\n            </div>\n          )}\n\n          {!headline.exact && (\n            <p className=\"pt-1 text-[11px] leading-4 text-amber-600 dark:text-amber-500\">\n              The provider did not report every field for this turn, so the total is a lower bound.\n              That is what the ~ means.\n            </p>\n          )}\n          {streaming && (\n            <p className=\"pt-1 text-[11px] leading-4 text-zinc-400 dark:text-zinc-500\">\n              Still generating — output tokens are a running subtotal.\n            </p>\n          )}\n        </dl>\n      )}\n    </div>\n  );\n}\n\nfunction Row({ label, value, hint, muted = false }: { label: string; value: string; hint?: string; muted?: boolean }) {\n  return (\n    <div className=\"flex items-baseline justify-between gap-3\">\n      <dt className={muted ? \"text-zinc-400 dark:text-zinc-500\" : \"text-zinc-500 dark:text-zinc-400\"}>\n        {label}\n        {hint && <span className=\"ml-1.5 text-zinc-300 dark:text-zinc-600\">{hint}</span>}\n      </dt>\n      <dd className={`shrink-0 tabular-nums ${muted ? \"text-zinc-400 dark:text-zinc-500\" : \"text-zinc-700 dark:text-zinc-200\"}`}>\n        {value}\n      </dd>\n    </div>\n  );\n}\n\nfunction money(usd: number): string {\n  return `$${usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)}`;\n}\n","type":"registry:ui","target":"components/ui/cost-meter.tsx"}]}