Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how. | 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 } 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 | export const SPEND_PERIODS: { key: SpendPeriod; label: string; short: string }[] = [ | |
| 15 | { key: "month", label: "This month", short: "This month" }, | |
| 16 | { key: "last_month", label: "Last month", short: "Last month" }, | |
| 17 | { key: "30d", label: "Last 30 days", short: "30 days" }, | |
| 18 | { key: "7d", label: "Last 7 days", short: "7 days" }, | |
| 19 | ]; | |
| 20 | ||
| 21 | export function readPeriod(raw: string | null | undefined): SpendPeriod { | |
| 22 | return SPEND_PERIODS.some((p) => p.key === raw) ? (raw as SpendPeriod) : "month"; | |
| 23 | } | |
| 24 | ||
| 25 | export function periodLabel(period: SpendPeriod): string { | |
| 26 | return SPEND_PERIODS.find((p) => p.key === period)?.label ?? "This month"; | |
| 27 | } | |
| 28 | ||
| 29 | /** Whose view a request asks for: the workspace's only for someone who may see it. */ | |
| 30 | export function readScope(raw: string | null | undefined, mayWorkspace: boolean): SpendScope { | |
| 31 | if (!mayWorkspace) return "me"; | |
| 32 | return raw === "me" ? "me" : "workspace"; | |
| 33 | } | |
| 34 | ||
| 35 | const iso = (at: Date) => at.toISOString().slice(0, 10); | |
| 36 | ||
| 37 | /** | |
| 38 | * The days a period covers in UTC, both ends included, as the agents | |
| 39 | * service counts them (services/agents/src/budget.ts `spendSpan`). | |
| 40 | */ | |
| 41 | export function spanFor(period: SpendPeriod, now: Date): { from: string; until: string } { | |
| 42 | const y = now.getUTCFullYear(); | |
| 43 | const m = now.getUTCMonth(); | |
| 44 | const d = now.getUTCDate(); | |
| 45 | switch (period) { | |
| 46 | case "last_month": | |
| 47 | return { from: iso(new Date(Date.UTC(y, m - 1, 1))), until: iso(new Date(Date.UTC(y, m, 0))) }; | |
| 48 | case "7d": | |
| 49 | return { from: iso(new Date(Date.UTC(y, m, d - 6))), until: iso(now) }; | |
| 50 | case "30d": | |
| 51 | return { from: iso(new Date(Date.UTC(y, m, d - 29))), until: iso(now) }; | |
| 52 | default: | |
| 53 | return { from: iso(new Date(Date.UTC(y, m, 1))), until: iso(now) }; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | /** Every day from `from` to `until`, with what was spent on it; days with none are 0. */ | |
| 58 | export function daySeries(from: string, until: string, spent: { day: string; micros: number }[]): { day: string; micros: number }[] { | |
| 59 | const by = new Map<string, number>(); | |
| 60 | for (const s of spent) by.set(s.day, (by.get(s.day) ?? 0) + s.micros); | |
| 61 | const out: { day: string; micros: number }[] = []; | |
| 62 | const at = new Date(`${from}T00:00:00Z`); | |
| 63 | const end = new Date(`${until}T00:00:00Z`); | |
| 64 | if (Number.isNaN(at.getTime()) || Number.isNaN(end.getTime())) return out; | |
| 65 | for (let n = 0; at <= end && n < 400; n++) { | |
| 66 | const key = iso(at); | |
| 67 | out.push({ day: key, micros: by.get(key) ?? 0 }); | |
| 68 | at.setUTCDate(at.getUTCDate() + 1); | |
| 69 | } | |
| 70 | return out; | |
| 71 | } | |
| 72 | ||
| 73 | /** Usage's days (one row per product a day) as one total a day. */ | |
| 74 | export function usageByDay(days: UsageDay[]): { day: string; micros: number }[] { | |
| 75 | return days.map((d) => ({ day: d.day, micros: d.micros })); | |
| 76 | } | |
| 77 | ||
| 78 | /** What the price book says about pricing, for the page's pricing card. */ | |
| 79 | export type Pricing = { | |
| 80 | /** What models are marked up, in percent: 0 is the provider's price. */ | |
| 81 | modelMarkupPercent: number; | |
| 82 | /** What g1t's own metered work is marked up: one figure, or the range across meters. */ | |
| 83 | markup: { min: number; max: number } | null; | |
| 84 | /** g1t's agent rate per million tokens, on g1t's models and on your own key. Null when the book has none. */ | |
| 85 | agentRateMicros: number | null; | |
| 86 | agentRateOwnMicros: number | null; | |
| 87 | }; | |
| 88 | ||
| 89 | const AGENT_METERS = new Set(["agent_models", "agent_tokens", "agent_tokens_own"]); | |
| 90 | ||
| 91 | export function pricingOf(book: PriceBook): Pricing { | |
| 92 | const run = book.prices.filter((p) => !AGENT_METERS.has(p.meter) && p.costMicros > 0); | |
| 93 | const markups = run.map((p) => p.markupPercent); | |
| 94 | const rate = (meter: string) => { | |
| 95 | const price = book.prices.find((p) => p.meter === meter); | |
| 96 | return price ? price.priceMicros : null; | |
| 97 | }; | |
| 98 | return { | |
| 99 | modelMarkupPercent: book.modelMarginPercent, | |
| 100 | markup: markups.length ? { min: Math.min(...markups), max: Math.max(...markups) } : null, | |
| 101 | agentRateMicros: rate("agent_tokens"), | |
| 102 | agentRateOwnMicros: rate("agent_tokens_own"), | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | /** "20%", or "15–20%" when meters differ. */ | |
| 107 | export function markupLabel(markup: { min: number; max: number }): string { | |
| 108 | return markup.min === markup.max ? `${markup.min}%` : `${markup.min}–${markup.max}%`; | |
| 109 | } | |
| 110 | ||
| 111 | /** One line of a task's receipt: one session of its tree. */ | |
| 112 | export type ReceiptLine = { | |
| 113 | session: AgentSession; | |
| 114 | depth: number; | |
| 115 | /** What this session's own steps were charged (a root's charge includes its tree's). */ | |
| 116 | ownMicros: number; | |
| 117 | }; | |
| 118 | ||
| 119 | export type Receipt = { | |
| 120 | root: AgentSession; | |
| 121 | lines: ReceiptLine[]; | |
| 122 | /** The whole tree, as the root's charge counts it. */ | |
| 123 | chargedMicros: number; | |
| 124 | /** The model answers at the provider's price, every session's together. */ | |
| 125 | providerMicros: number; | |
| 126 | inputTokens: number; | |
| 127 | outputTokens: number; | |
| 128 | steps: number; | |
| 129 | toolCalls: number; | |
| 130 | }; | |
| 131 | ||
| 132 | /** | |
| 133 | * A task's receipt from its session tree (root first, as the agents | |
| 134 | * service gives it): each session's own charge, children under their | |
| 135 | * parents, and the totals. A child's charge is added to the root as it is | |
| 136 | * spent, so the root's own share is its charge less its children's. | |
| 137 | */ | |
| 138 | export function receiptOf(tree: AgentSession[], rootId: string): Receipt | null { | |
| 139 | const root = tree.find((s) => s.id === rootId) ?? tree.find((s) => s.parent_id == null); | |
| 140 | if (!root) return null; | |
| 141 | const children = new Map<string, AgentSession[]>(); | |
| 142 | for (const s of tree) { | |
| 143 | if (s.id === root.id || !s.parent_id) continue; | |
| 144 | children.set(s.parent_id, [...(children.get(s.parent_id) ?? []), s]); | |
| 145 | } | |
| 146 | const lines: ReceiptLine[] = []; | |
| 147 | const seen = new Set<string>(); | |
| 148 | const walk = (s: AgentSession, depth: number) => { | |
| 149 | if (seen.has(s.id)) return; | |
| 150 | seen.add(s.id); | |
| 151 | lines.push({ session: s, depth, ownMicros: s.charged_micros }); | |
| 152 | for (const kid of [...(children.get(s.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at))) walk(kid, depth + 1); | |
| 153 | }; | |
| 154 | walk(root, 0); | |
| 155 | const others = lines.slice(1).reduce((n, l) => n + l.ownMicros, 0); | |
| 156 | lines[0]!.ownMicros = Math.max(0, root.charged_micros - others); | |
| 157 | const sum = (pick: (s: AgentSession) => number) => lines.reduce((n, l) => n + (pick(l.session) || 0), 0); | |
| 158 | return { | |
| 159 | root, | |
| 160 | lines, | |
| 161 | chargedMicros: root.charged_micros, | |
| 162 | providerMicros: sum((s) => s.cost_micros ?? 0), | |
| 163 | inputTokens: sum((s) => s.input_tokens), | |
| 164 | outputTokens: sum((s) => s.output_tokens), | |
| 165 | steps: sum((s) => s.steps), | |
| 166 | toolCalls: sum((s) => s.tool_calls), | |
| 167 | }; | |
| 168 | } | |
| 169 | ||
| 170 | /** A count of tokens for a receipt: "412K", "1.2M", "830". */ | |
| 171 | export function tokenCount(n: number): string { | |
| 172 | if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`; | |
| 173 | if (n >= 10_000) return `${Math.round(n / 1000).toLocaleString("en-US")}K`; | |
| 174 | if (n >= 1000) return `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; | |
| 175 | return n.toLocaleString("en-US"); | |
| 176 | } | |
| 177 | ||
| 178 | /** How far through a budget: 0 to 1 (more when over), null with none. */ | |
| 179 | export function shareOfBudget(spent: number, budget: number | null | undefined): number | null { | |
| 180 | return budget != null && budget > 0 ? Math.max(0, spent) / budget : null; | |
| 181 | } | |
| 182 | ||
| 183 | /** "62%" of a budget, for a pill or a row; empty with none. */ | |
| 184 | export function percentLabel(spent: number, budget: number | null | undefined): string { | |
| 185 | const share = shareOfBudget(spent, budget); | |
| 186 | return share == null ? "" : `${Math.round(share * 100)}%`; | |
| 187 | } | |
| 188 | ||
| 189 | /** The slices a person's own view leaves out: the "who asked" one is only ever them. */ | |
| 190 | export function withoutSelf(slices: SpendSlice[], username: string): SpendSlice[] { | |
| 191 | return slices.filter((s) => s.key !== username); | |
| 192 | } | |
| 193 | ||
| 194 | /** What a level of budget does when it is reached, as the page says it. */ | |
| 195 | export const AT_LIMIT = { | |
| 196 | workspace: (pauses: boolean) => | |
| 197 | pauses | |
| 198 | ? "New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. Work already running finishes." | |
| 199 | : "Owners are alerted, and work goes on; g1t's own ceiling still applies.", | |
| 200 | agents: "No agent takes new work until the 1st, or until an owner raises it.", | |
| 201 | person: "Agents take no new work for that person until the 1st; they say so where they were asked.", | |
| 202 | agent: "That agent takes no new work until the 1st, or the next day for a daily cap.", | |
| 203 | session: "The session stops at Needs approval, and an owner decides whether it goes on.", | |
| 204 | } as const; |