Elements

Elements · Observability

Cost meter

What the run spent, split by model, against the session total.

$2.76this run$18.40 session
Opus 548.2k in · 12.4k out$1.66
Sonnet 592.8k in · 21.1k out$0.86
Haiku 4.5140.0k in · 8.9k out$0.24
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-cost-meter"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

CostMeter shows what a run cost as a big number, a segmented bar split by model, and a row per model with its token counts and price. With a runtime you build the numbers from the message's own step usage; standalone you already hold them.

Getting started

assistant-ui tracks token usage per step of a run, but not dollars and not which model produced a given step, both of those are your own bookkeeping. The closest real data is message.metadata.steps, an array of { usage: { inputTokens, outputTokens } } entries.

Turn a run's steps into a cost line

components/assistant-ui/elements/run-cost.tsx
"use client";

import { useAuiState } from "@assistant-ui/react";
import {
  CostMeter,
  type CostLine,
} from "@/components/assistant-ui/elements/cost-meter";

const PRICE_PER_MILLION = { in: 3, out: 15 };

function useRunCost(model: string): CostLine {
  const usage = useAuiState((s) =>
    s.message.role === "assistant"
      ? s.message.metadata.steps.reduce(
          (total, step) => ({
            inputTokens: total.inputTokens + (step.usage?.inputTokens ?? 0),
            outputTokens: total.outputTokens + (step.usage?.outputTokens ?? 0),
          }),
          { inputTokens: 0, outputTokens: 0 },
        )
      : { inputTokens: 0, outputTokens: 0 },
  );
  const dollars =
    (usage.inputTokens / 1_000_000) * PRICE_PER_MILLION.in +
    (usage.outputTokens / 1_000_000) * PRICE_PER_MILLION.out;
  return { model, ...usage, cost: `$${dollars.toFixed(2)}`, share: 1 };
}

export function RunCost({ model }: { model: string }) {
  const line = useRunCost(model);
  return <CostMeter runCost={line.cost} sessionCost={line.cost} lines={[line]} />;
}

PRICE_PER_MILLION and the model label are yours; assistant-ui never names a price or a provider.

Add a session total

const sessionTokens = useAuiState((s) =>
  s.thread.messages.reduce(
    (total, message) =>
      message.role === "assistant"
        ? message.metadata.steps.reduce(
            (sum, step) =>
              sum + (step.usage?.inputTokens ?? 0) + (step.usage?.outputTokens ?? 0),
            total,
          )
        : total,
    0,
  ),
);

s.thread.messages is every message on the active branch, so summing its assistant messages' steps gives a real session total in tokens. Turning that into sessionCost still takes your own price table, and if a thread genuinely calls more than one model, keeping track of which message used which model is on you too, ThreadStep carries no model name.

Anatomy

<div data-slot="cost-meter">
  <div>
    <span>{/* runCost, large */}</span>
    <span>this run</span>
    <span>{/* "{sessionCost} session" */}</span>
  </div>
  <div>{/* segmented bar, one named meter per line, width = share */}</div>
  <div>
    {/* one row per line: model name, token counts, cost */}
  </div>
</div>

The segmented bar's color is assigned by index, not by value: the first line always gets the solid blue tone, the second a faded blue, and every line after that shares one neutral gray, regardless of how large its share is. Each painted segment is a named meter using that line's share as a 0…100 value; a line whose share rounds to no announced width is left out of the bar rather than announced as an empty segment, and the color still follows the line's own index. share isn't validated against the others, so shares that don't sum to 1 leave the bar under or overfull. inputTokens and outputTokens are raw numbers the component formats to one decimal in thousands (48200 becomes "48.2k in"); runCost, sessionCost, and each line's cost are already-formatted strings the component only displays, it does no currency math of its own.

Examples

Restyle the meter

Both lanes take className on the root. The root uses paper; token counts and cost use mono.

<CostMeter className="max-w-none" /* ... */ />

Computing share from raw costs

Whichever lane supplies the dollar amounts, share is just each line's fraction of the total, the bar doesn't compute this for you.

const raw = [
  { model: "Opus 5", inputTokens: 48_200, outputTokens: 12_400, dollars: 1.66 },
  { model: "Sonnet 5", inputTokens: 92_800, outputTokens: 21_100, dollars: 0.86 },
];
const total = raw.reduce((sum, r) => sum + r.dollars, 0);
const lines: CostLine[] = raw.map((r) => ({
  model: r.model,
  inputTokens: r.inputTokens,
  outputTokens: r.outputTokens,
  cost: `$${r.dollars.toFixed(2)}`,
  share: total > 0 ? r.dollars / total : 0,
}));

API reference

Message and thread state

SelectorTypeDescription
s.message.metadata.stepsreadonly ThreadStep[]Present on assistant messages. Each step's usage gives inputTokens / outputTokens for that step; sum across steps for one message's run cost.
s.thread.messagesreadonly MessageState[]Every message on the active branch. Sum .metadata.steps across its assistant messages for a session total.

assistant-ui tracks token counts, not dollars, and ThreadStep carries no model name. cost, share, and any per-model split are computed by your own price table and your own record of which model ran which message.