{
  "name": "cost-meter",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "cost-meter.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, themeVars as theme } from \"@yugnex/core\";\r\nimport { useMemo, type ReactNode } from \"react\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Model\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface UsageEntry {\r\n  /** Model identifier, used to group the breakdown. */\r\n  model: string;\r\n  inputTokens: number;\r\n  outputTokens: number;\r\n  /** Cached-read input tokens, billed at a lower rate when priced separately. */\r\n  cachedInputTokens?: number;\r\n  /** Epoch milliseconds. Required for burn rate. */\r\n  at?: number;\r\n  /** Overrides the computed cost for this entry, in currency units. */\r\n  cost?: number;\r\n}\r\n\r\n/** Price per **million** tokens — the unit model pricing is actually quoted in. */\r\nexport interface ModelPricing {\r\n  input: number;\r\n  output: number;\r\n  /** Defaults to `input` when a provider does not discount cache reads. */\r\n  cachedInput?: number;\r\n}\r\n\r\nexport type PricingTable = Record<string, ModelPricing>;\r\n\r\nexport interface ModelBreakdown {\r\n  model: string;\r\n  inputTokens: number;\r\n  outputTokens: number;\r\n  cachedInputTokens: number;\r\n  tokens: number;\r\n  cost: number;\r\n}\r\n\r\nexport interface CostSummary {\r\n  cost: number;\r\n  inputTokens: number;\r\n  outputTokens: number;\r\n  cachedInputTokens: number;\r\n  tokens: number;\r\n  byModel: ModelBreakdown[];\r\n  /** Currency units per hour over the measured window; undefined if unknowable. */\r\n  burnPerHour?: number;\r\n  /** Milliseconds spanned by the entries used for the burn rate. */\r\n  windowMs?: number;\r\n}\r\n\r\nconst MILLION = 1_000_000;\r\n\r\n/** Cost of one entry, in currency units. An explicit `cost` always wins. */\r\nexport function costOfEntry(entry: UsageEntry, pricing: PricingTable): number {\r\n  if (entry.cost !== undefined) return entry.cost;\r\n\r\n  const rates = pricing[entry.model];\r\n  if (!rates) return 0;\r\n\r\n  const cached = entry.cachedInputTokens ?? 0;\r\n  // Cached tokens are a *subset* of input tokens in most provider reporting,\r\n  // so bill the uncached remainder at full rate rather than double-counting.\r\n  const uncachedInput = Math.max(0, entry.inputTokens - cached);\r\n  const cachedRate = rates.cachedInput ?? rates.input;\r\n\r\n  return (\r\n    (uncachedInput * rates.input) / MILLION +\r\n    (cached * cachedRate) / MILLION +\r\n    (entry.outputTokens * rates.output) / MILLION\r\n  );\r\n}\r\n\r\nexport interface SummarizeOptions {\r\n  /**\r\n   * Burn rate window in ms. Only entries within this much of the newest one\r\n   * count, so a long-idle session reports the rate it is *currently* running\r\n   * at rather than an average diluted by the idle time.\r\n   */\r\n  burnWindowMs?: number;\r\n  /** Treated as \"now\" for the burn window. Defaults to the newest entry. */\r\n  now?: number;\r\n}\r\n\r\n/**\r\n * Aggregates usage into totals, a per-model breakdown, and a burn rate.\r\n *\r\n * Burn rate is deliberately measured over the span between the first and last\r\n * entry *in the window*, not against wall-clock since session start: an agent\r\n * that ran hard for two minutes and then sat idle for an hour is still burning\r\n * at its two-minute rate the moment it resumes, and averaging in the idle hour\r\n * would under-report the number a budget decision depends on.\r\n */\r\nexport function summarizeUsage(\r\n  entries: UsageEntry[],\r\n  pricing: PricingTable = {},\r\n  options: SummarizeOptions = {},\r\n): CostSummary {\r\n  const summary: CostSummary = {\r\n    cost: 0,\r\n    inputTokens: 0,\r\n    outputTokens: 0,\r\n    cachedInputTokens: 0,\r\n    tokens: 0,\r\n    byModel: [],\r\n  };\r\n\r\n  if (entries.length === 0) return summary;\r\n\r\n  const groups = new Map<string, ModelBreakdown>();\r\n\r\n  for (const entry of entries) {\r\n    const cost = costOfEntry(entry, pricing);\r\n    const cached = entry.cachedInputTokens ?? 0;\r\n\r\n    summary.cost += cost;\r\n    summary.inputTokens += entry.inputTokens;\r\n    summary.outputTokens += entry.outputTokens;\r\n    summary.cachedInputTokens += cached;\r\n\r\n    let group = groups.get(entry.model);\r\n    if (!group) {\r\n      group = {\r\n        model: entry.model,\r\n        inputTokens: 0,\r\n        outputTokens: 0,\r\n        cachedInputTokens: 0,\r\n        tokens: 0,\r\n        cost: 0,\r\n      };\r\n      groups.set(entry.model, group);\r\n    }\r\n    group.inputTokens += entry.inputTokens;\r\n    group.outputTokens += entry.outputTokens;\r\n    group.cachedInputTokens += cached;\r\n    group.tokens += entry.inputTokens + entry.outputTokens;\r\n    group.cost += cost;\r\n  }\r\n\r\n  summary.tokens = summary.inputTokens + summary.outputTokens;\r\n  summary.byModel = [...groups.values()].sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));\r\n\r\n  // Burn rate needs timestamps and at least two distinct instants to divide by.\r\n  const timed = entries.filter((entry) => entry.at !== undefined) as Array<UsageEntry & { at: number }>;\r\n  if (timed.length >= 2) {\r\n    const newest = options.now ?? Math.max(...timed.map((entry) => entry.at));\r\n    const windowMs = options.burnWindowMs;\r\n    const inWindow = windowMs === undefined ? timed : timed.filter((entry) => newest - entry.at <= windowMs);\r\n\r\n    if (inWindow.length >= 2) {\r\n      const first = Math.min(...inWindow.map((entry) => entry.at));\r\n      const last = Math.max(...inWindow.map((entry) => entry.at));\r\n      const span = last - first;\r\n\r\n      if (span > 0) {\r\n        const windowCost = inWindow.reduce((sum, entry) => sum + costOfEntry(entry, pricing), 0);\r\n        summary.burnPerHour = (windowCost / span) * 3_600_000;\r\n        summary.windowMs = span;\r\n      }\r\n    }\r\n  }\r\n\r\n  return summary;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Formatting\r\n * ------------------------------------------------------------------ */\r\n\r\n/**\r\n * Formats a cost, keeping small amounts legible.\r\n *\r\n * A per-request cost is routinely a fraction of a cent, and rounding that to\r\n * \"$0.00\" makes the meter useless exactly where people are watching it most\r\n * closely — so precision scales with magnitude instead of being fixed at two.\r\n */\r\nexport function formatCost(value: number, currency = \"$\"): string {\r\n  const abs = Math.abs(value);\r\n  if (abs === 0) return `${currency}0.00`;\r\n  if (abs < 0.01) return `${currency}${value.toFixed(4)}`;\r\n  if (abs < 1) return `${currency}${value.toFixed(3)}`;\r\n  if (abs < 1000) return `${currency}${value.toFixed(2)}`;\r\n  return `${currency}${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;\r\n}\r\n\r\n/** Compact token counts: 1234 -> 1.2K, 1234567 -> 1.2M. */\r\nexport function formatTokens(value: number): string {\r\n  if (value < 1000) return String(value);\r\n  if (value < MILLION) return `${(value / 1000).toFixed(1)}K`;\r\n  return `${(value / MILLION).toFixed(2)}M`;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst rootClass = css({\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  color: theme.color.foreground,\r\n  border: `1px solid ${theme.color.border}`,\r\n  borderRadius: theme.radius.md,\r\n  backgroundColor: theme.color.card,\r\n  padding: theme.space[3],\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  gap: theme.space[2.5],\r\n});\r\n\r\nconst headRowClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"baseline\",\r\n  gap: theme.space[2],\r\n  flexWrap: \"wrap\",\r\n});\r\n\r\nconst totalClass = css({\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize[\"2xl\"],\r\n  fontWeight: theme.fontWeight.semibold,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  lineHeight: 1,\r\n});\r\n\r\nconst labelClass = css({\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  textTransform: \"uppercase\",\r\n  letterSpacing: \"0.04em\",\r\n});\r\n\r\nconst burnClass = css({\r\n  marginLeft: \"auto\",\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  color: theme.color.mutedForeground,\r\n});\r\n\r\nconst trackClass = css({\r\n  position: \"relative\",\r\n  width: \"100%\",\r\n  height: \"8px\",\r\n  borderRadius: \"9999px\",\r\n  backgroundColor: theme.color.muted,\r\n  overflow: \"hidden\",\r\n});\r\n\r\nconst fillClass = css({\r\n  height: \"100%\",\r\n  borderRadius: \"9999px\",\r\n  transitionProperty: \"width, background-color\",\r\n  transitionDuration: theme.duration.slow,\r\n  transitionTimingFunction: theme.easing.decelerate,\r\n});\r\n\r\nconst budgetRowClass = css({\r\n  display: \"flex\",\r\n  justifyContent: \"space-between\",\r\n  gap: theme.space[2],\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n});\r\n\r\nconst tokenRowClass = css({\r\n  display: \"flex\",\r\n  gap: theme.space[3],\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  flexWrap: \"wrap\",\r\n});\r\n\r\nconst breakdownClass = css({\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  gap: theme.space[1],\r\n  paddingTop: theme.space[2],\r\n  borderTop: `1px solid ${theme.color.border}`,\r\n});\r\n\r\nconst modelRowClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  fontSize: theme.fontSize.xs,\r\n});\r\n\r\nconst modelNameClass = css({\r\n  flex: 1,\r\n  minWidth: 0,\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n  fontFamily: theme.fontFamily.mono,\r\n});\r\n\r\nconst modelBarClass = css({\r\n  width: \"4rem\",\r\n  height: \"5px\",\r\n  borderRadius: \"9999px\",\r\n  backgroundColor: theme.color.muted,\r\n  overflow: \"hidden\",\r\n  flexShrink: 0,\r\n});\r\n\r\nconst modelBarFillClass = css({\r\n  height: \"100%\",\r\n  borderRadius: \"9999px\",\r\n  backgroundColor: theme.color.primary,\r\n});\r\n\r\nconst modelCostClass = css({\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  minWidth: \"4rem\",\r\n  textAlign: \"right\",\r\n  flexShrink: 0,\r\n});\r\n\r\n/** Budget tiers. Colour shifts before the cap, not at it. */\r\nfunction tierColor(ratio: number): string {\r\n  if (ratio >= 1) return theme.color.destructive;\r\n  if (ratio >= 0.9) return theme.color.destructive;\r\n  if (ratio >= 0.75) return theme.color.warning;\r\n  return theme.color.primary;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface CostMeterProps {\r\n  entries: UsageEntry[];\r\n  /** Price per million tokens, keyed by model. */\r\n  pricing?: PricingTable;\r\n  /** Spend cap in currency units. Enables the budget bar. */\r\n  budget?: number;\r\n  currency?: string;\r\n  /** Burn-rate window in ms. Defaults to the whole session. */\r\n  burnWindowMs?: number;\r\n  /** Shows the per-model breakdown. */\r\n  showBreakdown?: boolean;\r\n  /** Shows the input/output token line. */\r\n  showTokens?: boolean;\r\n  label?: string;\r\n  /** Replaces the headline figure. */\r\n  headline?: ReactNode;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * Cumulative spend, burn rate, and an optional budget cap.\r\n *\r\n * Where `token-meter` answers \"how full is the context window\", this answers\r\n * \"how much has this session cost, and how fast is that growing\" — the\r\n * question that decides whether to let an agent keep running.\r\n */\r\nexport function CostMeter({\r\n  entries,\r\n  pricing = {},\r\n  budget,\r\n  currency = \"$\",\r\n  burnWindowMs,\r\n  showBreakdown = true,\r\n  showTokens = true,\r\n  label = \"Session cost\",\r\n  headline,\r\n  className,\r\n}: CostMeterProps) {\r\n  const summary = useMemo(\r\n    () => summarizeUsage(entries, pricing, { burnWindowMs }),\r\n    [entries, pricing, burnWindowMs],\r\n  );\r\n\r\n  const ratio = budget && budget > 0 ? summary.cost / budget : 0;\r\n  const clamped = Math.min(Math.max(ratio, 0), 1);\r\n  const color = tierColor(ratio);\r\n  const maxModelCost = summary.byModel.reduce((max, m) => Math.max(max, m.cost), 0);\r\n  const overBudget = budget !== undefined && budget > 0 && summary.cost > budget;\r\n\r\n  return (\r\n    <div className={className ? `${rootClass} ${className}` : rootClass} aria-label={label}>\r\n      <div className={headRowClass}>\r\n        {headline ?? (\r\n          <>\r\n            <span className={totalClass} style={overBudget ? { color: theme.color.destructive } : undefined}>\r\n              {formatCost(summary.cost, currency)}\r\n            </span>\r\n            <span className={labelClass}>spent</span>\r\n          </>\r\n        )}\r\n\r\n        {summary.burnPerHour !== undefined ? (\r\n          <span className={burnClass} title=\"Rate over the measured window\">\r\n            {formatCost(summary.burnPerHour, currency)}/hr\r\n          </span>\r\n        ) : null}\r\n      </div>\r\n\r\n      {budget !== undefined && budget > 0 ? (\r\n        <>\r\n          <div\r\n            className={trackClass}\r\n            role=\"progressbar\"\r\n            aria-valuemin={0}\r\n            aria-valuemax={budget}\r\n            aria-valuenow={Math.min(summary.cost, budget)}\r\n            aria-label={`${formatCost(summary.cost, currency)} of ${formatCost(budget, currency)} budget`}\r\n          >\r\n            <div className={fillClass} style={{ width: `${clamped * 100}%`, backgroundColor: color }} />\r\n          </div>\r\n          <div className={budgetRowClass}>\r\n            <span>{Math.round(ratio * 100)}% of budget</span>\r\n            <span style={overBudget ? { color: theme.color.destructive } : undefined}>\r\n              {overBudget\r\n                ? `${formatCost(summary.cost - budget, currency)} over`\r\n                : `${formatCost(budget - summary.cost, currency)} left`}\r\n            </span>\r\n          </div>\r\n        </>\r\n      ) : null}\r\n\r\n      {showTokens && summary.tokens > 0 ? (\r\n        <div className={tokenRowClass}>\r\n          <span>{formatTokens(summary.tokens)} tokens</span>\r\n          <span>in {formatTokens(summary.inputTokens)}</span>\r\n          <span>out {formatTokens(summary.outputTokens)}</span>\r\n          {summary.cachedInputTokens > 0 ? <span>cached {formatTokens(summary.cachedInputTokens)}</span> : null}\r\n        </div>\r\n      ) : null}\r\n\r\n      {showBreakdown && summary.byModel.length > 0 ? (\r\n        <div className={breakdownClass}>\r\n          {summary.byModel.map((model) => (\r\n            <div key={model.model} className={modelRowClass}>\r\n              <span className={modelNameClass} title={model.model}>\r\n                {model.model}\r\n              </span>\r\n              <span className={modelBarClass} aria-hidden=\"true\">\r\n                <span\r\n                  className={modelBarFillClass}\r\n                  style={{ width: maxModelCost > 0 ? `${(model.cost / maxModelCost) * 100}%` : \"0%\" }}\r\n                />\r\n              </span>\r\n              <span className={modelCostClass}>{formatCost(model.cost, currency)}</span>\r\n            </div>\r\n          ))}\r\n        </div>\r\n      ) : null}\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}