Skip to content
360 linesCodeBlameRaw

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.1import { ArrowRight } from "lucide-react";
2import { type ReactNode, Suspense } from "react";
3import { Await, Link, data } from "react-router";
4
5import type { AgentPolicy, AgentSpendBreakdown, SpendSlice, UsageReport } from "@g1t/contracts";
6
7import type { Route } from "./+types/spend";
8import { answer, agentsAction } from "../../components/agents/actions.server";
9import { RowsSkeleton } from "../../components/today";
10import {
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";
25import { Meter } from "../../components/agents/parts";
26import { Skeleton } from "../../components/ui/skeleton";
27import { microsFromDollars } from "../../lib/agent-form";
28import { cn } from "../../lib/cn";
29import { page } from "../../lib/meta";
30import { workspaceAgents } from "../../lib/services.server";
31import { managesBilling, requireUser, roleIn } from "../../lib/session.server";
32import { SPEND_PERIODS, type SpendScope, daySeries, periodLabel, readPeriod, readScope, spanFor, usageByDay, withoutSelf } from "../../lib/spend";
33import { loadBreakdown, loadBudgets, loadPricing, loadUsage } from "../../lib/spend.server";
34import { money } from "../../lib/usage";
35
36export 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 */
49export 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
81function 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. */
87export 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. */
111function 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
121export 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&apos;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&apos;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
283function countOf(slices: SpendSlice[]): string {
284 return slices.reduce((n, s) => n + s.count, 0).toLocaleString("en-US");
285}
286
287function 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. */
293function 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. */
311function 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
353function Unavailable({ what }: { what: string }) {
354 return (
355 <SlicePanel>
356 <p className="px-4 py-6 text-center text-sm text-muted">{what} didn&apos;t answer. Reload in a moment.</p>
357 </SlicePanel>
358 );
359}
360