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