| 1 | /** |
| 2 | * Spend's arithmetic and wording (routes/workspace/spend.tsx and the top |
| 3 | * bar's spend pill): which days a period covers, what the price book says |
| 4 | * about pricing, a day-by-day series, a task's receipt from its session |
| 5 | * tree, and what each budget does when it is reached. Pure, with type-only |
| 6 | * imports, so it is tested on its own. Every number comes from a service; |
| 7 | * nothing here invents one. |
| 8 | */ |
| 9 | import type { AgentSession, PriceBook, SpendPeriod, SpendSlice, UsageDay, UsageReport, UsageShare } from "@g1t/contracts"; |
| 10 | |
| 11 | /** Whose spend: your own (what agents did for you), or the whole workspace's. */ |
| 12 | export type SpendScope = "me" | "workspace"; |
| 13 | |
| 14 | /** |
| 15 | * What agents cost over the report's range, at price: the agent product |
| 16 | * (model tokens, the agent rate and agents' sandbox time), every agent's |
| 17 | * replies, sessions and runs on repositories together. The top bar's and |
| 18 | * Spend's "Agents" figure, from the same ledger as "Spent", so it is part |
| 19 | * of it: Spent = Agents + the other products. |
| 20 | */ |
| 21 | export function agentMicros(report: Pick<UsageReport, "products">): number { |
| 22 | return report.products.find((p) => p.key === "agent")?.micros ?? 0; |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * The agent product by the agent that did the work and by who asked, as |
| 27 | * Spend's slices, from the ledger's own attribution. Null when billing did |
| 28 | * not attribute the range (a report from before attribution), so the |
| 29 | * caller can fall back to the agents service's own count. |
| 30 | */ |
| 31 | export function attributionSlices(report: Pick<UsageReport, "byAgent" | "byPerson"> | null): { agent: SpendSlice[]; person: SpendSlice[] } | null { |
| 32 | if (!report?.byAgent || !report.byPerson) return null; |
| 33 | const slice = (s: UsageShare): SpendSlice => ({ key: s.key, label: s.label, micros: s.micros, count: s.count }); |
| 34 | return { agent: report.byAgent.map(slice), person: report.byPerson.map(slice) }; |
| 35 | } |
| 36 | |
| 37 | /** Where an agent's slice opens: its own Spend tab, for a slice that names one. */ |
| 38 | export function agentSpendHref(slug: string, slice: Pick<SpendSlice, "key">): string | null { |
| 39 | return slice.key ? `/${slug}/-/agents/${slice.key}/spend` : null; |
| 40 | } |
| 41 | |
| 42 | export const SPEND_PERIODS: { key: SpendPeriod; label: string; short: string }[] = [ |
| 43 | { key: "month", label: "This month", short: "This month" }, |
| 44 | { key: "last_month", label: "Last month", short: "Last month" }, |
| 45 | { key: "30d", label: "Last 30 days", short: "30 days" }, |
| 46 | { key: "7d", label: "Last 7 days", short: "7 days" }, |
| 47 | ]; |
| 48 | |
| 49 | export function readPeriod(raw: string | null | undefined): SpendPeriod { |
| 50 | return SPEND_PERIODS.some((p) => p.key === raw) ? (raw as SpendPeriod) : "month"; |
| 51 | } |
| 52 | |
| 53 | export function periodLabel(period: SpendPeriod): string { |
| 54 | return SPEND_PERIODS.find((p) => p.key === period)?.label ?? "This month"; |
| 55 | } |
| 56 | |
| 57 | /** Whose view a request asks for: the workspace's only for someone who may see it. */ |
| 58 | export function readScope(raw: string | null | undefined, mayWorkspace: boolean): SpendScope { |
| 59 | if (!mayWorkspace) return "me"; |
| 60 | return raw === "me" ? "me" : "workspace"; |
| 61 | } |
| 62 | |
| 63 | const iso = (at: Date) => at.toISOString().slice(0, 10); |
| 64 | |
| 65 | /** |
| 66 | * This month, in UTC: its first day to today, both included. The one |
| 67 | * range "this month" means on every page: the top bar's pill, Spend's |
| 68 | * "This month", Home's "this month so far" and Usage's current cycle all |
| 69 | * read it, so the same month shows the same number everywhere. Billing |
| 70 | * adds the month's usage not yet closed (`pending_micros`) to any range |
| 71 | * that reaches into the current month, so `until` need not be month end. |
| 72 | */ |
| 73 | export function monthSpan(now: Date): { from: string; until: string } { |
| 74 | return { from: iso(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))), until: iso(now) }; |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * What the workspace used over the report's range, at price: the figure |
| 79 | * the pill, Spend's "Spent", Home's "this month so far" and Usage's |
| 80 | * "Usage at price" all show. Billing already measures at cost while g1t |
| 81 | * is free, so the report's price is the one figure in every mode. |
| 82 | */ |
| 83 | export function spentMicros(report: Pick<UsageReport, "totals">): number { |
| 84 | return report.totals.priceMicros; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * The days a period covers in UTC, both ends included, as the agents |
| 89 | * service counts them (services/agents/src/budget.ts `spendSpan`). |
| 90 | */ |
| 91 | export function spanFor(period: SpendPeriod, now: Date): { from: string; until: string } { |
| 92 | const y = now.getUTCFullYear(); |
| 93 | const m = now.getUTCMonth(); |
| 94 | const d = now.getUTCDate(); |
| 95 | switch (period) { |
| 96 | case "last_month": |
| 97 | return { from: iso(new Date(Date.UTC(y, m - 1, 1))), until: iso(new Date(Date.UTC(y, m, 0))) }; |
| 98 | case "7d": |
| 99 | return { from: iso(new Date(Date.UTC(y, m, d - 6))), until: iso(now) }; |
| 100 | case "30d": |
| 101 | return { from: iso(new Date(Date.UTC(y, m, d - 29))), until: iso(now) }; |
| 102 | default: |
| 103 | return monthSpan(now); |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /** Every day from `from` to `until`, with what was spent on it; days with none are 0. */ |
| 108 | export function daySeries(from: string, until: string, spent: { day: string; micros: number }[]): { day: string; micros: number }[] { |
| 109 | const by = new Map<string, number>(); |
| 110 | for (const s of spent) by.set(s.day, (by.get(s.day) ?? 0) + s.micros); |
| 111 | const out: { day: string; micros: number }[] = []; |
| 112 | const at = new Date(`${from}T00:00:00Z`); |
| 113 | const end = new Date(`${until}T00:00:00Z`); |
| 114 | if (Number.isNaN(at.getTime()) || Number.isNaN(end.getTime())) return out; |
| 115 | for (let n = 0; at <= end && n < 400; n++) { |
| 116 | const key = iso(at); |
| 117 | out.push({ day: key, micros: by.get(key) ?? 0 }); |
| 118 | at.setUTCDate(at.getUTCDate() + 1); |
| 119 | } |
| 120 | return out; |
| 121 | } |
| 122 | |
| 123 | /** Usage's days (one row per product a day) as one total a day. */ |
| 124 | export function usageByDay(days: UsageDay[]): { day: string; micros: number }[] { |
| 125 | return days.map((d) => ({ day: d.day, micros: d.micros })); |
| 126 | } |
| 127 | |
| 128 | /** What the price book says about pricing, for the page's pricing card. */ |
| 129 | export type Pricing = { |
| 130 | /** What models are marked up, in percent: 0 is the provider's price. */ |
| 131 | modelMarkupPercent: number; |
| 132 | /** What g1t's own metered work is marked up: one figure, or the range across meters. */ |
| 133 | markup: { min: number; max: number } | null; |
| 134 | /** g1t's agent rate per million tokens, on g1t's models and on your own key, in force now. Null when the book has none. */ |
| 135 | agentRateMicros: number | null; |
| 136 | agentRateOwnMicros: number | null; |
| 137 | /** The agent rate still to come, when it is $0 now and a dated version waits: its price and the day it starts. */ |
| 138 | agentRateComing: { micros: number; from: string } | null; |
| 139 | }; |
| 140 | |
| 141 | /** |
| 142 | * Meters that are not something g1t runs at a cost: models (at the |
| 143 | * provider's price), AI Gateway, card fees (Stripe's), flat activations, |
| 144 | * and per-token rates with their weights. |
| 145 | */ |
| 146 | const NOT_RUN = (meter: string) => |
| 147 | meter === "agent_models" || meter === "gateway_models" || meter.startsWith("agent_token") || meter.startsWith("card_fee") || meter === "security_activation"; |
| 148 | |
| 149 | export function pricingOf(book: PriceBook): Pricing { |
| 150 | const run = book.prices.filter((p) => !NOT_RUN(p.meter) && p.costMicros > 0); |
| 151 | const markups = run.map((p) => p.markupPercent); |
| 152 | const rate = (meter: string) => { |
| 153 | const price = book.prices.find((p) => p.meter === meter); |
| 154 | return price ? price.priceMicros : null; |
| 155 | }; |
| 156 | const coming = book.changes.find((c) => c.meter === "agent_tokens" && c.effectiveAt); |
| 157 | return { |
| 158 | modelMarkupPercent: book.modelMarginPercent, |
| 159 | markup: markups.length ? { min: Math.min(...markups), max: Math.max(...markups) } : null, |
| 160 | agentRateMicros: rate("agent_tokens"), |
| 161 | agentRateOwnMicros: rate("agent_tokens_own"), |
| 162 | agentRateComing: coming ? { micros: coming.newCostMicros, from: coming.effectiveAt!.slice(0, 10) } : null, |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | /** "$0.25 per million tokens", or "$0.25 per million tokens from 2026-10-22" while it waits for its date. */ |
| 167 | export function agentRateLabel(pricing: Pick<Pricing, "agentRateMicros" | "agentRateComing">, money: (micros: number) => string): string { |
| 168 | if (pricing.agentRateMicros && pricing.agentRateMicros > 0) return `${money(pricing.agentRateMicros)} per million tokens`; |
| 169 | if (pricing.agentRateComing) return `${money(pricing.agentRateComing.micros)} per million tokens from ${pricing.agentRateComing.from}`; |
| 170 | return "Per million tokens"; |
| 171 | } |
| 172 | |
| 173 | /** "20%", or "15–20%" when meters differ. */ |
| 174 | export function markupLabel(markup: { min: number; max: number }): string { |
| 175 | return markup.min === markup.max ? `${markup.min}%` : `${markup.min}–${markup.max}%`; |
| 176 | } |
| 177 | |
| 178 | /** One line of a task's receipt: one session of its tree. */ |
| 179 | export type ReceiptLine = { |
| 180 | session: AgentSession; |
| 181 | depth: number; |
| 182 | /** What this session's own steps were charged (a root's charge includes its tree's). */ |
| 183 | ownMicros: number; |
| 184 | }; |
| 185 | |
| 186 | export type Receipt = { |
| 187 | root: AgentSession; |
| 188 | lines: ReceiptLine[]; |
| 189 | /** The whole tree, as the root's charge counts it. */ |
| 190 | chargedMicros: number; |
| 191 | /** The model answers at the provider's price, every session's together. */ |
| 192 | providerMicros: number; |
| 193 | inputTokens: number; |
| 194 | outputTokens: number; |
| 195 | steps: number; |
| 196 | toolCalls: number; |
| 197 | }; |
| 198 | |
| 199 | /** |
| 200 | * A task's receipt from its session tree (root first, as the agents |
| 201 | * service gives it): each session's own charge, children under their |
| 202 | * parents, and the totals. A child's charge is added to the root as it is |
| 203 | * spent, so the root's own share is its charge less its children's. |
| 204 | */ |
| 205 | export function receiptOf(tree: AgentSession[], rootId: string): Receipt | null { |
| 206 | const root = tree.find((s) => s.id === rootId) ?? tree.find((s) => s.parent_id == null); |
| 207 | if (!root) return null; |
| 208 | const children = new Map<string, AgentSession[]>(); |
| 209 | for (const s of tree) { |
| 210 | if (s.id === root.id || !s.parent_id) continue; |
| 211 | children.set(s.parent_id, [...(children.get(s.parent_id) ?? []), s]); |
| 212 | } |
| 213 | const lines: ReceiptLine[] = []; |
| 214 | const seen = new Set<string>(); |
| 215 | const walk = (s: AgentSession, depth: number) => { |
| 216 | if (seen.has(s.id)) return; |
| 217 | seen.add(s.id); |
| 218 | lines.push({ session: s, depth, ownMicros: s.charged_micros }); |
| 219 | for (const kid of [...(children.get(s.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at))) walk(kid, depth + 1); |
| 220 | }; |
| 221 | walk(root, 0); |
| 222 | const others = lines.slice(1).reduce((n, l) => n + l.ownMicros, 0); |
| 223 | lines[0]!.ownMicros = Math.max(0, root.charged_micros - others); |
| 224 | const sum = (pick: (s: AgentSession) => number) => lines.reduce((n, l) => n + (pick(l.session) || 0), 0); |
| 225 | return { |
| 226 | root, |
| 227 | lines, |
| 228 | chargedMicros: root.charged_micros, |
| 229 | providerMicros: sum((s) => s.cost_micros ?? 0), |
| 230 | inputTokens: sum((s) => s.input_tokens), |
| 231 | outputTokens: sum((s) => s.output_tokens), |
| 232 | steps: sum((s) => s.steps), |
| 233 | toolCalls: sum((s) => s.tool_calls), |
| 234 | }; |
| 235 | } |
| 236 | |
| 237 | /** A count of tokens for a receipt: "412K", "1.2M", "830". */ |
| 238 | export function tokenCount(n: number): string { |
| 239 | if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`; |
| 240 | if (n >= 10_000) return `${Math.round(n / 1000).toLocaleString("en-US")}K`; |
| 241 | if (n >= 1000) return `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; |
| 242 | return n.toLocaleString("en-US"); |
| 243 | } |
| 244 | |
| 245 | /** How far through a budget: 0 to 1 (more when over), null with none. */ |
| 246 | export function shareOfBudget(spent: number, budget: number | null | undefined): number | null { |
| 247 | return budget != null && budget > 0 ? Math.max(0, spent) / budget : null; |
| 248 | } |
| 249 | |
| 250 | /** "62%" of a budget, for a pill or a row; empty with none. */ |
| 251 | export function percentLabel(spent: number, budget: number | null | undefined): string { |
| 252 | const share = shareOfBudget(spent, budget); |
| 253 | return share == null ? "" : `${Math.round(share * 100)}%`; |
| 254 | } |
| 255 | |
| 256 | /** The slices a person's own view leaves out: the "who asked" one is only ever them. */ |
| 257 | export function withoutSelf(slices: SpendSlice[], username: string): SpendSlice[] { |
| 258 | return slices.filter((s) => s.key !== username); |
| 259 | } |
| 260 | |
| 261 | /** What a level of budget does when it is reached, as the page says it. */ |
| 262 | export const AT_LIMIT = { |
| 263 | workspace: (pauses: boolean) => |
| 264 | pauses |
| 265 | ? "New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. Work already running finishes." |
| 266 | : "Owners are alerted, and work goes on; g1t's own ceiling still applies.", |
| 267 | agents: "No agent takes new work until the 1st, or until an owner raises it.", |
| 268 | person: "Agents take no new work for that person until the 1st; they say so where they were asked.", |
| 269 | agent: "That agent takes no new work until the 1st, or the next day for a daily cap.", |
| 270 | session: "The session stops at Needs approval, and an owner decides whether it goes on.", |
| 271 | } as const; |