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 | import { ArrowRight } from "lucide-react"; |
| 2 | import { type ReactNode, Suspense } from "react"; | |
| 3 | import { Await, Link, data } from "react-router"; | |
| 4 | ||
| 5 | import type { AgentPolicy, AgentSpendBreakdown, SpendSlice, UsageReport } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import type { Route } from "./+types/spend"; | |
| 8 | import { answer, agentsAction } from "../../components/agents/actions.server"; | |
| 9 | import { RowsSkeleton } from "../../components/today"; | |
| 10 | import { | |
| 11 | BudgetLadder, | |
| 12 | ComingNote, | |
| 13 | PricingCard, | |
| 14 | Section, | |
| 15 | SliceList, | |
| 16 | SlicePanel, | |
| 17 | type SliceKey, | |
| 18 | SLICES, | |
| 19 | SliceTabs, | |
| 20 | SpendBars, | |
| 21 | TaskList, | |
| 22 | Tile, | |
| 23 | TileSkeleton, | |
| 24 | } from "../../components/spend"; | |
| 25 | import { Meter } from "../../components/agents/parts"; | |
| 26 | import { Skeleton } from "../../components/ui/skeleton"; | |
| 27 | import { microsFromDollars } from "../../lib/agent-form"; | |
| 28 | import { cn } from "../../lib/cn"; | |
| 29 | import { page } from "../../lib/meta"; | |
| 30 | import { workspaceAgents } from "../../lib/services.server"; | |
| 31 | import { managesBilling, requireUser, roleIn } from "../../lib/session.server"; | |
| 32 | import { SPEND_PERIODS, type SpendScope, daySeries, periodLabel, readPeriod, readScope, spanFor, usageByDay, withoutSelf } from "../../lib/spend"; | |
| 33 | import { loadBreakdown, loadBudgets, loadPricing, loadUsage } from "../../lib/spend.server"; | |
| 34 | import { money } from "../../lib/usage"; | |
| 35 | ||
| 36 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 37 | return page(args, { title: `Spend · ${params.owner} · g1t` }); | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Spend: the front of the workspace's money. What was spent over a | |
| 42 | * period, where it went (by agent, person, channel, model, kind of work and | |
| 43 | * product), the budgets from the workspace down to one task with what | |
| 44 | * happens at each, the costliest tasks with their receipts, and how it is | |
| 45 | * priced. Owners and billing managers see the workspace's, or their own; | |
| 46 | * everyone else sees their own: what agents did for them. Each part | |
| 47 | * streams in from its own service. Definitions: lib/spend.ts. | |
| 48 | */ | |
| 49 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 50 | const viewer = requireUser(context, request); | |
| 51 | const slug = params.owner.toLowerCase(); | |
| 52 | const role = roleIn(viewer, slug); | |
| 53 | if (!role) throw data(null, { status: 404 }); | |
| 54 | const url = new URL(request.url); | |
| 55 | const mayWorkspace = managesBilling(viewer, slug); | |
| 56 | const scope = readScope(url.searchParams.get("scope"), mayWorkspace); | |
| 57 | const period = readPeriod(url.searchParams.get("period")); | |
| 58 | const by = readSlice(url.searchParams.get("by"), scope); | |
| 59 | const now = new Date(); | |
| 60 | ||
| 61 | const breakdown = loadBreakdown(viewer, slug, scope, period); | |
| 62 | // The budgets are monthly: every agent's month, read once when the page is on it already. | |
| 63 | const month = scope === "workspace" && period === "month" ? breakdown : loadBreakdown(viewer, slug, "workspace", "month"); | |
| 64 | const usage = scope === "workspace" ? loadUsage(viewer, slug, period, now) : Promise.resolve(null); | |
| 65 | return { | |
| 66 | slug, | |
| 67 | me: viewer.username.toLowerCase(), | |
| 68 | owner: role === "owner", | |
| 69 | mayWorkspace, | |
| 70 | scope, | |
| 71 | period, | |
| 72 | by, | |
| 73 | span: spanFor(period, now), | |
| 74 | breakdown, | |
| 75 | usage, | |
| 76 | budgets: loadBudgets(viewer, slug, month), | |
| 77 | pricing: loadPricing(), | |
| 78 | }; | |
| 79 | } | |
| 80 | ||
| 81 | function readSlice(raw: string | null, scope: SpendScope): SliceKey { | |
| 82 | const found = SLICES.find((s) => s.key === raw && (scope === "workspace" || !s.workspaceOnly)); | |
| 83 | return found?.key ?? "agent"; | |
| 84 | } | |
| 85 | ||
| 86 | /** Owners set the agent budgets: every agent together, each person, a new agent, a session; and one person's own. */ | |
| 87 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 88 | const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner); | |
| 89 | if (!isOwner) return { ok: false as const, error: "Only the workspace's owners set budgets." }; | |
| 90 | const intent = form.get("intent"); | |
| 91 | if (intent === "policy") { | |
| 92 | const monthly = microsFromDollars(form.get("monthly")); | |
| 93 | const person = microsFromDollars(form.get("person")); | |
| 94 | const agent = microsFromDollars(form.get("default_agent")); | |
| 95 | const session = microsFromDollars(form.get("default_session")); | |
| 96 | if ([monthly, person, agent, session].some((v) => v != null && Number.isNaN(v))) return { ok: false as const, intent, error: "Write each amount in dollars, such as 250 or 2.50." }; | |
| 97 | const changes: Partial<AgentPolicy> = { monthly_micros: monthly, person_monthly_micros: person, default_agent_monthly_micros: agent }; | |
| 98 | if (session != null) changes.default_session_micros = session; | |
| 99 | return answer("policy", workspaceAgents.setPolicy(slug, viewer, changes)); | |
| 100 | } | |
| 101 | if (intent === "person") { | |
| 102 | const username = String(form.get("username") ?? "").trim(); | |
| 103 | const amount = microsFromDollars(form.get("amount")); | |
| 104 | if (amount != null && Number.isNaN(amount)) return { ok: false as const, intent, error: "Write the amount in dollars, such as 50, or 0 for no budget." }; | |
| 105 | return answer("person", workspaceAgents.setPersonBudget(slug, viewer, username, amount)); | |
| 106 | } | |
| 107 | return { ok: false as const, error: "Unknown request." }; | |
| 108 | } | |
| 109 | ||
| 110 | /** The page's address with some of its query changed. */ | |
| 111 | function hrefFor(slug: string, q: { scope: SpendScope; period: string; by: SliceKey }, change: Partial<{ scope: SpendScope; period: string; by: SliceKey }>): string { | |
| 112 | const next = { ...q, ...change }; | |
| 113 | const params = new URLSearchParams(); | |
| 114 | if (next.scope === "me") params.set("scope", "me"); | |
| 115 | if (next.period !== "month") params.set("period", next.period); | |
| 116 | if (next.by !== "agent") params.set("by", next.by); | |
| 117 | const query = params.toString(); | |
| 118 | return `/${slug}/-/spend${query ? `?${query}` : ""}`; | |
| 119 | } | |
| 120 | ||
| 121 | export default function SpendPage({ loaderData }: Route.ComponentProps) { | |
| 122 | const { slug, me, owner, mayWorkspace, scope, period, by, span, breakdown, usage, budgets, pricing } = loaderData; | |
| 123 | const q = { scope, period, by }; | |
| 124 | const href = (change: Partial<typeof q>) => hrefFor(slug, q, change); | |
| 125 | const label = periodLabel(period).toLowerCase(); | |
| 126 | return ( | |
| 127 | <div className="mx-auto w-full max-w-[1040px] space-y-8 px-4 py-5 md:px-10 md:py-8"> | |
| 128 | <header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> | |
| 129 | <div className="min-w-0"> | |
| 130 | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Spend</h1> | |
| 131 | <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted"> | |
| 132 | {scope === "workspace" | |
| 133 | ? "What the workspace spent, where it went, and the budgets that hold it, from the workspace down to one task." | |
| 134 | : "What agents spent working for you: your chats with them and the sessions you started, against your budget."} | |
| 135 | </p> | |
| 136 | </div> | |
| 137 | <div className="flex flex-wrap items-center gap-2"> | |
| 138 | {mayWorkspace && ( | |
| 139 | <div role="group" aria-label="Whose spend" className="inline-flex rounded-lg border border-line bg-surface p-0.5"> | |
| 140 | {(["workspace", "me"] as const).map((key) => ( | |
| 141 | <Link | |
| 142 | key={key} | |
| 143 | to={href({ scope: key, by: "agent" })} | |
| 144 | aria-current={scope === key ? "page" : undefined} | |
| 145 | className={cn("inline-flex h-7 items-center rounded-md px-3 text-[0.8125rem] transition-colors", scope === key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg")} | |
| 146 | > | |
| 147 | {key === "workspace" ? "Workspace" : "You"} | |
| 148 | </Link> | |
| 149 | ))} | |
| 150 | </div> | |
| 151 | )} | |
| 152 | <nav aria-label="Period" className="inline-flex rounded-lg border border-line bg-surface p-0.5"> | |
| 153 | {SPEND_PERIODS.map((p) => ( | |
| 154 | <Link | |
| 155 | key={p.key} | |
| 156 | to={href({ period: p.key })} | |
| 157 | preventScrollReset | |
| 158 | aria-current={period === p.key ? "page" : undefined} | |
| 159 | className={cn( | |
| 160 | "inline-flex h-7 items-center rounded-md px-2.5 text-[0.8125rem] whitespace-nowrap transition-colors", | |
| 161 | period === p.key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg", | |
| 162 | )} | |
| 163 | > | |
| 164 | {p.short} | |
| 165 | </Link> | |
| 166 | ))} | |
| 167 | </nav> | |
| 168 | </div> | |
| 169 | </header> | |
| 170 | ||
| 171 | {/* The figures at the top. */} | |
| 172 | <Suspense | |
| 173 | fallback={ | |
| 174 | <div className="grid gap-3 sm:grid-cols-3"> | |
| 175 | <TileSkeleton /> | |
| 176 | <TileSkeleton /> | |
| 177 | <TileSkeleton /> | |
| 178 | </div> | |
| 179 | } | |
| 180 | > | |
| 181 | <Await resolve={Promise.all([breakdown, usage, budgets])}> | |
| 182 | {([spent, used, levels]) => ( | |
| 183 | <div className="grid gap-3 sm:grid-cols-3"> | |
| 184 | {scope === "workspace" ? ( | |
| 185 | <> | |
| 186 | <Tile label={`Spent, ${label}`} value={used ? money(used.free ? used.totals.costMicros : used.totals.priceMicros) : "—"} sub={used ? (used.free ? "At cost: g1t charges nothing for now" : "Usage at price, every product") : "Usage couldn't be read"} /> | |
| 187 | <Tile label={`Agents, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions` : "The agents service didn't answer"} /> | |
| 188 | <Tile | |
| 189 | label="Spend limit, this month" | |
| 190 | value={levels.limit?.spentMicros != null ? money(levels.limit.spentMicros) : "—"} | |
| 191 | sub={levels.limit ? limitLine(levels.limit.spentMicros ?? 0, levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) : "Billing couldn't be read"} | |
| 192 | > | |
| 193 | {levels.limit && (levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) != null && ( | |
| 194 | <Meter spent={levels.limit.spentMicros ?? 0} cap={levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros} label="Charged of the spend limit" size="sm" className="mt-2.5" /> | |
| 195 | )} | |
| 196 | </Tile> | |
| 197 | </> | |
| 198 | ) : ( | |
| 199 | <MyTiles spent={spent} levels={levels} me={me} label={label} /> | |
| 200 | )} | |
| 201 | </div> | |
| 202 | )} | |
| 203 | </Await> | |
| 204 | </Suspense> | |
| 205 | ||
| 206 | {/* Spend by day. */} | |
| 207 | <Section id="by-day" title="By day" aside={`${periodLabel(period)}, UTC`}> | |
| 208 | <div className="rounded-xl border border-line bg-surface px-4 pt-4 pb-3"> | |
| 209 | <Suspense fallback={<Skeleton className="h-32 w-full" />}> | |
| 210 | <Await resolve={Promise.all([breakdown, usage])}> | |
| 211 | {([spent, used]) => { | |
| 212 | const days = scope === "workspace" && used ? usageByDay(used.days) : (spent?.days ?? null); | |
| 213 | return days ? <SpendBars days={daySeries(span.from, span.until, days)} /> : <p className="py-8 text-center text-sm text-muted">Spend by day couldn't be read right now.</p>; | |
| 214 | }} | |
| 215 | </Await> | |
| 216 | </Suspense> | |
| 217 | </div> | |
| 218 | </Section> | |
| 219 | ||
| 220 | {/* Where it went. */} | |
| 221 | <Section id="where" title="Where it went" aside={scope === "workspace" ? "Agent work by who and where; every product from usage" : "Agent work you asked for"}> | |
| 222 | <SliceTabs current={by} scope={scope} href={(key) => href({ by: key })} /> | |
| 223 | <Suspense | |
| 224 | fallback={ | |
| 225 | <SlicePanel> | |
| 226 | <RowsSkeleton /> | |
| 227 | </SlicePanel> | |
| 228 | } | |
| 229 | > | |
| 230 | <Await resolve={Promise.all([breakdown, usage])}>{([spent, used]) => <Slices by={by} spent={spent} used={used} slug={slug} me={me} scope={scope} />}</Await> | |
| 231 | </Suspense> | |
| 232 | </Section> | |
| 233 | ||
| 234 | {/* Budgets. */} | |
| 235 | <Section id="budgets" title="Budgets" aside="Monthly, UTC. Checked before work starts, widest first"> | |
| 236 | <Suspense | |
| 237 | fallback={ | |
| 238 | <SlicePanel> | |
| 239 | <RowsSkeleton rows={5} /> | |
| 240 | </SlicePanel> | |
| 241 | } | |
| 242 | > | |
| 243 | <Await resolve={budgets}>{(levels) => <BudgetLadder slug={slug} budgets={levels} owner={owner} me={me} action={`/${slug}/-/spend`} workspaceFigures={mayWorkspace} />}</Await> | |
| 244 | </Suspense> | |
| 245 | </Section> | |
| 246 | ||
| 247 | {/* Receipts. */} | |
| 248 | <Section id="tasks" title="Costliest tasks" aside="Open one for its receipt"> | |
| 249 | <SlicePanel> | |
| 250 | <Suspense fallback={<RowsSkeleton />}> | |
| 251 | <Await resolve={breakdown}>{(spent) => (spent ? <TaskList slug={slug} sessions={spent.top_sessions} /> : <p className="px-4 py-6 text-center text-sm text-muted">Tasks couldn't be read right now.</p>)}</Await> | |
| 252 | </Suspense> | |
| 253 | </SlicePanel> | |
| 254 | </Section> | |
| 255 | ||
| 256 | {/* Pricing. */} | |
| 257 | <Section | |
| 258 | id="pricing" | |
| 259 | title="How it's priced" | |
| 260 | aside={ | |
| 261 | <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1 hover:text-fg"> | |
| 262 | Every meter on Usage <ArrowRight size={11} /> | |
| 263 | </Link> | |
| 264 | } | |
| 265 | > | |
| 266 | <Suspense fallback={<Skeleton className="h-36 w-full rounded-xl" />}> | |
| 267 | <Await resolve={pricing}>{(value) => <PricingCard pricing={value} />}</Await> | |
| 268 | </Suspense> | |
| 269 | </Section> | |
| 270 | ||
| 271 | <p className="text-xs text-faint"> | |
| 272 | Agent figures count every reply and session at list price, as budgets do. What the workspace is charged, after included usage, credit and any | |
| 273 | discount, is on{" "} | |
| 274 | <Link to={`/${slug}/-/billing`} className="hover:text-fg hover:underline"> | |
| 275 | Billing | |
| 276 | </Link> | |
| 277 | . | |
| 278 | </p> | |
| 279 | </div> | |
| 280 | ); | |
| 281 | } | |
| 282 | ||
| 283 | function countOf(slices: SpendSlice[]): string { | |
| 284 | return slices.reduce((n, s) => n + s.count, 0).toLocaleString("en-US"); | |
| 285 | } | |
| 286 | ||
| 287 | function limitLine(spent: number, limit: number | null): string { | |
| 288 | if (limit == null) return "No spend limit"; | |
| 289 | return `Charged, of ${money(limit)} · ${Math.round((spent / Math.max(1, limit)) * 100)}%`; | |
| 290 | } | |
| 291 | ||
| 292 | /** Your own figures: what agents spent for you, and your budget. */ | |
| 293 | function MyTiles({ spent, levels, me, label }: { spent: AgentSpendBreakdown | null; levels: Awaited<Route.ComponentProps["loaderData"]["budgets"]>; me: string; label: string }) { | |
| 294 | const people = levels.people; | |
| 295 | const mine = people?.people.find((p) => p.username === me) ?? null; | |
| 296 | const budget = mine ? mine.monthly_micros : (people?.default_micros ?? null); | |
| 297 | const month = mine?.spent_micros ?? null; | |
| 298 | const replies = spent?.by_kind.find((s) => s.key === "reply"); | |
| 299 | return ( | |
| 300 | <> | |
| 301 | <Tile label={`Agents for you, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions you asked for` : "The agents service didn't answer"} /> | |
| 302 | <Tile label="Your budget, this month" value={budget != null ? money(budget) : "None"} sub={budget != null ? `${month != null ? money(month) : "—"} spent · ${mine?.own ? "set for you" : "the workspace's default"}` : "The workspace's limits apply"}> | |
| 303 | {budget != null && month != null && <Meter spent={month} cap={budget} label="Spent of your budget" size="sm" className="mt-2.5" />} | |
| 304 | </Tile> | |
| 305 | <Tile label={`Your chats with agents, ${label}`} value={replies ? money(replies.micros) : spent ? money(0) : "—"} sub={replies ? `${replies.count.toLocaleString("en-US")} replies` : "Talking costs only what the model answers"} /> | |
| 306 | </> | |
| 307 | ); | |
| 308 | } | |
| 309 | ||
| 310 | /** The breakdown for the slice chosen. */ | |
| 311 | function Slices({ by, spent, used, slug, me, scope }: { by: SliceKey; spent: AgentSpendBreakdown | null; used: UsageReport | null; slug: string; me: string; scope: SpendScope }) { | |
| 312 | if (by === "extension") { | |
| 313 | return ( | |
| 314 | <SlicePanel> | |
| 315 | <ComingNote title="Spend by extension">Once extensions can be installed, what each one runs and what agents do in it will show here, priced like everything else.</ComingNote> | |
| 316 | </SlicePanel> | |
| 317 | ); | |
| 318 | } | |
| 319 | if (by === "product") { | |
| 320 | if (!used) return <Unavailable what="Usage" />; | |
| 321 | const slices: SpendSlice[] = used.products.map((p) => ({ key: p.key, label: p.label, micros: p.micros, count: 0 })); | |
| 322 | return ( | |
| 323 | <SlicePanel foot="Every product, at price, from Usage. Code and Deployments are built in."> | |
| 324 | <SliceList slices={slices} empty="Nothing used in this period." href={() => `/${slug}/-/usage`} /> | |
| 325 | </SlicePanel> | |
| 326 | ); | |
| 327 | } | |
| 328 | if (!spent) return <Unavailable what="The agents service" />; | |
| 329 | const pick: Record<Exclude<SliceKey, "extension" | "product">, { slices: SpendSlice[]; unit: string; foot?: ReactNode; link?: (s: SpendSlice) => string | null }> = { | |
| 330 | agent: { | |
| 331 | slices: spent.by_agent, | |
| 332 | unit: "runs", | |
| 333 | foot: "A session's helpers and subagents count on the agent that started it.", | |
| 334 | link: (s) => { | |
| 335 | const handle = /\(@([^)]+)\)$/.exec(s.label)?.[1]; | |
| 336 | return handle ? `/${slug}/-/agents/${handle}/spend` : null; | |
| 337 | }, | |
| 338 | }, | |
| 339 | person: { slices: spent.by_person, unit: "runs", foot: "Who asked. Routines and agent-to-agent work have no asker." }, | |
| 340 | channel: { slices: spent.by_channel, unit: "runs", foot: "Where it was asked. Channels you aren't in show only what they cost." }, | |
| 341 | model: { slices: spent.by_model, unit: "runs" }, | |
| 342 | kind: { slices: spent.by_kind, unit: "" }, | |
| 343 | }; | |
| 344 | const chosen = pick[by]; | |
| 345 | const slices = scope === "me" && by === "person" ? withoutSelf(chosen.slices, me) : chosen.slices; | |
| 346 | return ( | |
| 347 | <SlicePanel foot={chosen.foot}> | |
| 348 | <SliceList slices={slices} unit={chosen.unit} empty="Nothing spent in this period." href={chosen.link} /> | |
| 349 | </SlicePanel> | |
| 350 | ); | |
| 351 | } | |
| 352 | ||
| 353 | function Unavailable({ what }: { what: string }) { | |
| 354 | return ( | |
| 355 | <SlicePanel> | |
| 356 | <p className="px-4 py-6 text-center text-sm text-muted">{what} didn't answer. Reload in a moment.</p> | |
| 357 | </SlicePanel> | |
| 358 | ); | |
| 359 | } | |
| 360 |