Skip to content

Commit

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.

syntaqxcommitted Parentc439e66Browse files
28 files+2238−500/28 viewed
+3−1
5151 | `@g1t`, the orchestrator every workspace has, which hands work to the right agent with `hand_off` | Live |
5252 | Your own model providers' keys (14 providers), and the AI Gateway | Live |
5353 | g1t's hosted models (open to invited workspaces during the alpha) | Preview |
54−| Budgets per workspace, agent and task; guardrails; agents act with the asker's access | Live |
54+| Budgets per workspace, person, agent and task; guardrails; agents act with the asker's access | Live |
55+| Spend: where the money went by agent, person, channel, model and product, every budget, a receipt for each task, and your spend in the top bar | Preview |
56+| Spend by extension | Coming |
5557 | Memory, sessions with live steps and cost, routines on a schedule | Live |
5658 | An agent catalog of specialists, and foundational skills (documents, research, data, code, communication, files) | Coming |
5759 | Agents on runners anywhere (g1t's, yours, your desktop), with sessions that persist between tasks | Coming |
+1−0
9292 { label: 'Sessions', slug: 'guides/agent-sessions' },
9393 { label: 'Agent memory', slug: 'guides/agent-memory' },
9494 { label: 'Routines', slug: 'guides/agent-routines' },
95+ { label: 'Spend', slug: 'guides/spend' },
9596 { label: 'Agent budgets and spend', slug: 'guides/agent-budgets' },
9697 { label: 'What agents can do for whom', slug: 'guides/agent-access' },
9798 { label: 'Artifacts', slug: 'guides/artifacts' },
+5−0
1717 | --- | --- | --- |
1818 | **The workspace's spend limit and AI credit** | Owners and billing managers, under [Billing](/guides/usage-and-billing/#your-spend-limit). | A hard stop for everything, agents included. |
1919 | **The agent budget** | Owners, under **Agents → Budget**. | Every agent's work together, each month. At 100% no agent takes new work until the 1st, or until an owner raises it. |
20+| **A person's budget** | Owners, under [Workspace → Spend](/guides/spend/#budgets): a default for everyone, and a budget of their own for anyone. | What agents spend on the work one person asks for, each month. At 100% agents take no new work for that person until the 1st, and say so where they were asked. |
2021 | **An agent's own budget** | Owners, on the agent's **Profile**. | Monthly, and optionally daily. At 100% that agent takes no new work. |
2122 | **A session's cap** | The workspace's session cap, or the agent's lower per-session cap. | The session stops at **Needs approval** until an owner approves more. |
2223
5253
5354 ## Where the month went
5455
56+[Spend](/guides/spend/) has the same breakdown over any period, by
57+channel too, with each task's receipt; everyone can see their own there.
58+
5559 The **Agents** page breaks down every agent's spend this month:
5660
5761 | View | Shows |
7882
7983 ## Next
8084
85+- [Spend](/guides/spend/): every budget in one place, and receipts.
8186 - [Sessions](/guides/agent-sessions/): caps and approving more spend.
8287 - [Agents](/guides/agents/#budgets): an agent's own budget.
8388 - [Usage and billing](/guides/usage-and-billing/): spend limits, AI credit and rates.
+134−0
1+---
2+title: Spend
3+description: What the workspace spent and where it went, by agent, person, channel, model and product; the budgets from the workspace down to one task, and what happens at each; a receipt for every task; and how it is priced.
4+---
5+
6+**Spend** is the front of the workspace's money:
7+`g1t.sh/<workspace>/-/spend`, first under **Money** in the Workspace
8+sidebar. It says what was spent over a period, where it went, the budgets
9+that hold it, and what each task cost. Every figure on it comes from the
10+workspace's usage and agent records; nothing is estimated.
11+
12+**Usage** has every meter in detail, and **Billing and plans** has the
13+plan, the spend limit, payments and the statement. Spend links to both.
14+
15+## Whose spend
16+
17+| View | Who sees it | What it counts |
18+| --- | --- | --- |
19+| **Workspace** | Owners and [billing managers](/guides/workspaces/#roles-that-add-to-a-member) | Everything the workspace used, every product, and every agent's work. |
20+| **You** | Everyone | What agents spent working for you: your chats with agents and the sessions you started. |
21+
22+Owners and billing managers switch between them with **Workspace** and
23+**You** at the top of the page. Everyone else sees **You**.
24+
25+## The period
26+
27+Choose **This month**, **Last month**, **30 days** or **7 days**. Days are
28+UTC. Budgets are always this month's, whatever the period.
29+
30+## The figures at the top
31+
32+| View | Figures |
33+| --- | --- |
34+| **Workspace** | **Spent**: usage at price over the period, every product, the same figure as [Usage](/guides/usage-and-billing/#the-usage-page). **Agents**: every agent's replies and sessions. **Spend limit**: what was charged this month against the workspace's spend limit. |
35+| **You** | **Agents for you** over the period. **Your budget** this month, and what is spent against it. **Your chats with agents**: the replies agents wrote you. |
36+
37+**By day** shows the period one day at a time. Hover or focus a day for
38+its amount.
39+
40+## Where it went
41+
42+Slice the period's spend with the tabs:
43+
44+| Slice | Shows |
45+| --- | --- |
46+| **Agents** | Each agent's share. A session's helpers and subagents count on the agent that started it. Open one for its own Spend tab. |
47+| **People** | Who asked. Routines and agent-to-agent work have no asker. Workspace view only. |
48+| **Channels** | Where it was asked: each channel, direct messages together, and channels you aren't in together, without their names. |
49+| **Models** | Each model's share. |
50+| **Kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. |
51+| **Products** | Every product at price, from Usage: Agent, Sandboxes, AI Gateway, Deployments, Git & storage, Packages, Security & quality and Search. Workspace view only. |
52+| **Extensions** | Coming: what each installed extension runs, once extensions can be installed. |
53+
54+A slice is part of the page's address (`?by=channel`), so you can share it.
55+
56+## Budgets
57+
58+Budgets nest, from the widest to the narrowest. Before every agent reply
59+and session step, g1t checks each level, and the first one that is used
60+up stops the work. Each row on Spend shows the budget, what is spent
61+against it this month, and what happens at 100%.
62+
63+| Level | Set by | At 100% |
64+| --- | --- | --- |
65+| **The workspace** | Owners and billing managers, under [Billing → Your spend limit](/guides/usage-and-billing/#your-spend-limit). | New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. With **Pause usage at 100%** off, owners are alerted and work goes on. |
66+| **All agents together** | Owners, here or under **Agents → Budget**. | No agent takes new work until the 1st, or until an owner raises it. |
67+| **Each person** | Owners, here. A default for everyone, and a budget of their own for anyone. | Agents take no new work for that person until the 1st, and say so where they were asked. |
68+| **Each agent** | Owners, on the agent's **Profile**. A new agent starts with the default set here. | That agent takes no new work until the 1st, or the next day for a daily cap. |
69+| **Each task** | Owners, here: the cap a session starts with. The plan's caps on one run and one issue are under [Billing → Caps](/guides/usage-and-billing/#caps). | The session stops at **Needs approval**, and an owner decides whether it goes on. |
70+
71+Work already running finishes, so spend can go slightly past a budget.
72+
73+### A person's budget
74+
75+A person's budget counts what agents spend on work that person asks for:
76+the replies agents write them and the sessions they start, with
77+everything those sessions bring in. Each person can see their own on
78+**You**, and in the top bar.
79+
80+To set budgets, as an owner:
81+
82+1. Open **Spend** and find **Budgets**.
83+2. Choose **Change** on **All agents together**, **Each person** or
84+ **Each task**, fill in the amounts in dollars, and choose **Save
85+ budgets**. An empty budget means none.
86+3. To give one person their own budget, choose **Set** on their row, or
87+ **Give someone their own budget**, and enter it. `0` means no budget at
88+ all for them; empty puts them back on the default.
89+
90+## Receipts
91+
92+**Costliest tasks** lists the period's most expensive sessions. Open one
93+for its receipt, at `g1t.sh/<workspace>/-/spend/receipts/<session>`:
94+
95+- Each session of its tree: the agent's own, and every helper and subagent
96+ it brought in, with their tokens, the model at the provider's price, and
97+ what each was charged.
98+- **Models, at the provider's price**: every session's model answers
99+ together.
100+- **g1t's agent rate**: the rest, at the rate in the price book.
101+- **Total, as budgets count it**.
102+
103+On your own model key, your provider bills the model and only the agent
104+rate is charged; the receipt says so. A session from a conversation you
105+aren't in shows what it cost, not what it was about.
106+
107+## How it's priced
108+
109+The page's last section reads the [price book](/guides/usage-and-billing/#how-prices-are-set):
110+
111+| | Price |
112+| --- | --- |
113+| **Models** | What the model provider charges, with no markup. |
114+| **Agent rate** | Per million tokens an agent's run uses, for context, memory, routing and orchestration. |
115+| **Everything g1t runs** | Sandboxes, builds, hosting, storage and search, at cost plus 20%. |
116+| **Your own model keys** | Your provider bills the model; only the agent rate is charged. Local models the same. |
117+| **Your own runners** | $0. |
118+| **People** | No seats. |
119+
120+## In the top bar
121+
122+Your spend this month sits in the page's header, beside **Ask g1t**: what
123+agents did for you against your budget. Choose it for where it went, by
124+kind of work and by agent, and a link to Spend. Owners and billing
125+managers can switch it to **Workspace**: what the workspace was charged
126+this month against its spend limit, and its agents' spend. The choice is
127+remembered on this browser. On a phone, open **Spend** from the Workspace
128+sidebar instead.
129+
130+## Next
131+
132+- [Agent budgets and spend](/guides/agent-budgets/): who pays for a session tree.
133+- [Usage and billing](/guides/usage-and-billing/): the spend limit, caps, AI credit and rates.
134+- [Sessions](/guides/agent-sessions/): caps and approving more spend.
+3−2
537537 | --- | --- | --- |
538538 | **The dock** | A rounded bar down the left | g1t's mark, which goes to [Today](/guides/today/); the built-in apps; the apps you pinned; **Apps**; and at its foot **People**, **Workspace** and your account. |
539539 | **The sidebar** | Beside the dock | The workspace and its switcher at the top, then the lists of the app you are in. Today and Apps have none. |
540−| **The page** | A rounded panel, the rest of the window | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>Ctrl</kbd> <kbd>K</kbd>, or <kbd>⌘</kbd> <kbd>K</kbd> on a Mac), **Ask g1t**, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. |
540+| **The page** | A rounded panel, the rest of the window | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>Ctrl</kbd> <kbd>K</kbd>, or <kbd>⌘</kbd> <kbd>K</kbd> on a Mac), [your spend this month](/guides/spend/#in-the-top-bar), **Ask g1t**, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. |
541541
542542 ### The dock
543543
633633 for the roles that can use it. A link straight to any of these pages opens
634634 the sidebar already there.
635635
636−Workspace's sidebar lists the workspace's **Overview**, **Usage**,
636+Workspace's sidebar lists the workspace's **Overview**; under **Money**,
637+[**Spend**](/guides/spend/), **Usage**,
637638 **Billing and plans**, **Integrations**, its security policies, the
638639 **Audit log** and **Settings**, which slides over to how the workspace is
639640 set up:
+2−1
7979 | [g1t, the orchestrator](/guides/agents/) | Every workspace's own agent. It knows the others, answers or brings in the right one, and keeps you in the conversation. | <Status is="live" /> |
8080 | [Model providers](/guides/models/) | Agents use your own provider's key, or g1t's hosted models through the [AI Gateway](/guides/ai-gateway/). | <Status is="live" /> |
8181 | g1t's hosted models | Models g1t runs for you, billed on your workspace. Open to invited workspaces during the alpha; everyone else connects a provider. | <Status is="preview" /> |
82−| [Budgets](/guides/agent-budgets/) and [guardrails](/guides/guardrails/) | Spend limits per workspace, agent and task, checked before work starts, and what an agent may do alone. | <Status is="live" /> |
82+| [Budgets](/guides/agent-budgets/) and [guardrails](/guides/guardrails/) | Spend limits per workspace, person, agent and task, checked before work starts, and what an agent may do alone. | <Status is="live" /> |
83+| [Spend](/guides/spend/) | Where the money went, by agent, person, channel, model and product; every budget with what happens at 100%; a receipt for each task; your own spend in the top bar. | <Status is="preview" /> |
8384 | [Acting for the asker](/guides/agent-access/) | Agents act with the access of the person who asked, and answer with only what their audience may see. | <Status is="live" /> |
8485 | [Memory](/guides/agent-memory/), [sessions](/guides/agent-sessions/) and [routines](/guides/agent-routines/) | What an agent keeps, every run with its steps and cost, and work on a schedule. | <Status is="live" /> |
8586 | Agent catalog | Specialists to hire from a catalog, each with a role, skills, tools and the runner it prefers. | <Status is="coming" /> |
+15−2
1−import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
1+import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
22 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
77 import { InMain } from "./landmark";
88 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
99 import { NotificationsBell } from "./inbox";
10+import { SpendPill } from "./spend";
1011 import { PinButton } from "./pin-button";
1112 import { Hint } from "./ui/hint";
1213 import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
11661167 Overview
11671168 </SidebarLink>
11681169 </div>
1169− <SidebarGroup title="Usage and billing" className="mt-3">
1170+ <SidebarGroup title="Money" className="mt-3">
1171+ <SidebarLink to={`/${slug}/-/spend`} icon={<Coins size={15} />} current={at("spend")}>
1172+ Spend
1173+ </SidebarLink>
11701174 <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage", "gateway")}>
11711175 Usage
11721176 </SidebarLink>
13761380 teams: "Teams",
13771381 tokens: "Access tokens",
13781382 "personal-access-tokens": "Personal access tokens",
1383+ spend: "Spend",
1384+ receipts: "Receipts",
13791385 usage: "Usage",
13801386 gateway: "AI Gateway",
13811387 billing: "Billing and plans",
16071613 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
16081614 { label: "All projects", hint: membership.slug, to: `/${membership.slug}/-/projects`, icon: <LayoutGrid size={15} /> },
16091615 { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> },
1616+ { label: "Spend", hint: `${membership.slug} · Budgets and receipts`, to: `/${membership.slug}/-/spend`, icon: <Coins size={15} /> },
16101617 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
16111618 { label: "AI Gateway", hint: `${membership.slug} · Usage`, to: `/${membership.slug}/-/gateway`, icon: <Network size={15} /> },
16121619 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
19601967 <span className="hidden grow truncate text-left text-[0.8125rem] md:inline">Search or jump to</span>
19611968 <PaletteKey className="hidden rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line md:inline" />
19621969 </button>
1970+ {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */}
1971+ {ws && (
1972+ <span className="max-md:hidden">
1973+ <SpendPill slug={ws.slug} mayWorkspace={ws.role === "owner" || !!ws.org_roles?.includes("billing_manager")} />
1974+ </span>
1975+ )}
19631976 {ws && <AskG1tButton slug={ws.slug} />}
19641977 {/* On a phone, Notifications is in the bottom bar. */}
19651978 <span className="max-md:hidden">
+720−0
1+/**
2+ * Spend's parts: the tiles at the top, spend by day, where it went, the
3+ * budgets from the workspace down to a task with what happens at each,
4+ * the costliest tasks with their receipts, pricing, and the top bar's
5+ * spend pill. Every figure is one a service returned (lib/spend.server.ts).
6+ */
7+import { ArrowRight, Building2, Coins, Cpu, KeyRound, MessagesSquare, Puzzle, ReceiptText, Sparkles, UserRound, Users } from "lucide-react";
8+import { type ReactNode, useEffect, useId, useState } from "react";
9+import { Link, useFetcher } from "react-router";
10+
11+import type { AgentPolicy, AgentSession, PersonBudget } from "@g1t/contracts";
12+
13+import { AgentAvatar } from "./agent-avatar";
14+import { BUTTONS, DollarsInput, useDialogFetcher } from "./agents/dialogs";
15+import { Meter, PrivateTitle, SliceList, stepsLine } from "./agents/parts";
16+import { monthName, shortDay } from "./agents/format";
17+import { Badge } from "./ui/badge";
18+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
19+import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
20+import { Hint } from "./ui/hint";
21+import { Input } from "./ui/input";
22+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
23+import { Skeleton } from "./ui/skeleton";
24+import { TimeAgo } from "./ui";
25+import { dollarsField } from "../lib/agent-form";
26+import { cn } from "../lib/cn";
27+import type { AgentBudgetRow, Budgets, PillData } from "../lib/spend.server";
28+import { AT_LIMIT, type Pricing, type SpendScope, markupLabel, percentLabel, shareOfBudget } from "../lib/spend";
29+import { money } from "../lib/usage";
30+
31+
32+/** A figure at the top of the page: what it is, the amount, and a line under it. */
33+export function Tile({ label, value, sub, children }: { label: ReactNode; value: ReactNode; sub?: ReactNode; children?: ReactNode }) {
34+ return (
35+ <div className="min-w-0 rounded-xl border border-line bg-surface p-4">
36+ <p className="flex items-center gap-1.5 text-xs text-muted">{label}</p>
37+ <p className="mt-1.5 truncate text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
38+ {sub && <p className="mt-0.5 text-xs text-faint">{sub}</p>}
39+ {children}
40+ </div>
41+ );
42+}
43+
44+export function TileSkeleton() {
45+ return (
46+ <div className="rounded-xl border border-line bg-surface p-4">
47+ <Skeleton className="h-3 w-24" />
48+ <Skeleton className="mt-3 h-7 w-28" />
49+ <Skeleton className="mt-2 h-3 w-36" />
50+ </div>
51+ );
52+}
53+
54+/**
55+ * Spend by day over any span, one bar a day with its amount on hover or
56+ * focus, and a table for screen readers.
57+ */
58+export function SpendBars({ days, className }: { days: { day: string; micros: number }[]; className?: string }) {
59+ const max = Math.max(0, ...days.map((d) => d.micros));
60+ return (
61+ <div className={className}>
62+ <div className="flex h-28 items-end gap-0.5" aria-hidden="true">
63+ {days.map((d) => (
64+ <Hint key={d.day} label={`${shortDay(d.day)} · ${money(d.micros)}`}>
65+ <span className="flex h-full min-w-0 flex-1 items-end rounded-t-[3px] hover:bg-raised/60">
66+ <span
67+ className={cn("block w-full rounded-t-[3px]", d.micros > 0 ? "bg-accent/80" : "bg-line/70")}
68+ style={{ height: max > 0 && d.micros > 0 ? `${Math.max(4, (d.micros / max) * 100)}%` : "2px" }}
69+ />
70+ </span>
71+ </Hint>
72+ ))}
73+ </div>
74+ <div className="mt-1.5 flex justify-between text-[0.6875rem] text-faint" aria-hidden="true">
75+ <span>{days[0] ? shortDay(days[0].day) : ""}</span>
76+ <span>{days.length > 1 ? shortDay(days[days.length - 1]!.day) : ""}</span>
77+ </div>
78+ <table className="sr-only">
79+ <caption>Spend by day</caption>
80+ <tbody>
81+ {days.map((d) => (
82+ <tr key={d.day}>
83+ <th scope="row">{shortDay(d.day)}</th>
84+ <td>{money(d.micros)}</td>
85+ </tr>
86+ ))}
87+ </tbody>
88+ </table>
89+ </div>
90+ );
91+}
92+
93+/** A section with a heading, an aside, and what it holds. */
94+export function Section({ id, title, aside, children, className }: { id: string; title: ReactNode; aside?: ReactNode; children: ReactNode; className?: string }) {
95+ return (
96+ <section aria-labelledby={id} className={cn("space-y-3", className)}>
97+ <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
98+ <h2 id={id} className="text-base font-semibold tracking-tight">
99+ {title}
100+ </h2>
101+ {aside && <div className="text-xs text-faint">{aside}</div>}
102+ </div>
103+ {children}
104+ </section>
105+ );
106+}
107+
108+/** The ways to slice spend, as the page's tabs. */
109+export type SliceKey = "agent" | "person" | "channel" | "model" | "kind" | "product" | "extension";
110+
111+export const SLICES: { key: SliceKey; label: string; icon: ReactNode; workspaceOnly?: boolean }[] = [
112+ { key: "agent", label: "Agents", icon: <Sparkles size={13} /> },
113+ { key: "person", label: "People", icon: <Users size={13} />, workspaceOnly: true },
114+ { key: "channel", label: "Channels", icon: <MessagesSquare size={13} /> },
115+ { key: "model", label: "Models", icon: <Cpu size={13} /> },
116+ { key: "kind", label: "Kind of work", icon: <ReceiptText size={13} /> },
117+ { key: "product", label: "Products", icon: <Building2 size={13} />, workspaceOnly: true },
118+ { key: "extension", label: "Extensions", icon: <Puzzle size={13} />, workspaceOnly: true },
119+];
120+
121+/** The tabs over the breakdown: each a link, so a slice is an address. */
122+export function SliceTabs({ current, scope, href }: { current: SliceKey; scope: SpendScope; href: (key: SliceKey) => string }) {
123+ const shown = SLICES.filter((s) => scope === "workspace" || !s.workspaceOnly);
124+ return (
125+ <nav aria-label="Slice spend by" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1 [scrollbar-width:none]">
126+ {shown.map((s) => (
127+ <Link
128+ key={s.key}
129+ to={href(s.key)}
130+ preventScrollReset
131+ replace
132+ aria-current={s.key === current ? "page" : undefined}
133+ className={cn(
134+ "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[0.8125rem] transition-colors",
135+ s.key === current ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:bg-raised/60 hover:text-fg",
136+ )}
137+ >
138+ <span className="text-faint">{s.icon}</span>
139+ {s.label}
140+ {s.key === "extension" && <span className="ml-0.5 text-[0.625rem] tracking-wide text-faint uppercase">Coming</span>}
141+ </Link>
142+ ))}
143+ </nav>
144+ );
145+}
146+
147+/** Spend in a panel, the slices ranked. */
148+export function SlicePanel({ children, foot }: { children: ReactNode; foot?: ReactNode }) {
149+ return (
150+ <div className="overflow-hidden rounded-xl border border-line bg-surface">
151+ {children}
152+ {foot && <p className="border-t border-line px-4 py-2.5 text-xs text-faint">{foot}</p>}
153+ </div>
154+ );
155+}
156+
157+export { SliceList };
158+
159+/** Something not built yet, said plainly where it will be. */
160+export function ComingNote({ title, children }: { title: string; children: ReactNode }) {
161+ return (
162+ <div className="px-4 py-8 text-center">
163+ <p className="flex items-center justify-center gap-2 text-sm font-medium">
164+ {title}
165+ <Badge>Coming</Badge>
166+ </p>
167+ <p className="mx-auto mt-1.5 max-w-md text-sm text-muted">{children}</p>
168+ </div>
169+ );
170+}
171+
172+// ── Budgets ──────────────────────────────────────────────────────────────
173+
174+/** One level of budget: its name, its amount, what is spent against it, and what happens at 100%. */
175+function Level({
176+ icon,
177+ title,
178+ amount,
179+ spent,
180+ budget,
181+ atLimit,
182+ action,
183+ children,
184+}: {
185+ icon: ReactNode;
186+ title: string;
187+ amount: ReactNode;
188+ spent?: number | null;
189+ budget?: number | null;
190+ atLimit: string;
191+ action?: ReactNode;
192+ children?: ReactNode;
193+}) {
194+ const share = spent != null ? shareOfBudget(spent, budget) : null;
195+ return (
196+ <li className="px-4 py-4">
197+ <div className="flex flex-wrap items-start gap-x-3 gap-y-2">
198+ <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">{icon}</span>
199+ <div className="min-w-0 grow basis-56">
200+ <p className="text-sm font-medium">{title}</p>
201+ <p className="mt-0.5 text-xs text-muted">
202+ <span className="font-medium text-fg-soft">At 100%:</span> {atLimit}
203+ </p>
204+ </div>
205+ <div className="flex shrink-0 items-center gap-3 max-sm:w-full max-sm:justify-between max-sm:pl-10">
206+ <div className="sm:text-right">
207+ <p className="text-sm tabular-nums">{amount}</p>
208+ {spent != null && (
209+ <p className="text-xs text-faint tabular-nums">
210+ {money(spent)} spent{share != null ? ` · ${percentLabel(spent, budget)}` : ""}
211+ </p>
212+ )}
213+ </div>
214+ {action}
215+ </div>
216+ </div>
217+ {share != null && <Meter spent={spent!} cap={budget} label={`${title}: spent of its budget`} size="sm" className="mt-3 ml-10" />}
218+ {children}
219+ </li>
220+ );
221+}
222+
223+const EDIT = "inline-flex h-8 items-center rounded-md border border-line px-2.5 text-xs font-medium text-fg/85 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg";
224+
225+/**
226+ * The budgets, widest first: the workspace's spend limit, every agent
227+ * together, each person, each agent, and each task. Owners change the
228+ * agent levels here; the spend limit is on Billing, an agent's own on its
229+ * profile.
230+ */
231+export function BudgetLadder({ slug, budgets, owner, me, action, workspaceFigures }: { slug: string; budgets: Budgets; owner: boolean; me: string; action: string; workspaceFigures: boolean }) {
232+ const { limit, caps, policy, people, agents } = budgets;
233+ const limitMicros = limit ? (limit.spendLimitMicros ?? limit.ceilingMicros) : null;
234+ const mine = people?.people.find((p) => p.username === me) ?? null;
235+ return (
236+ <ol className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
237+ <Level
238+ icon={<Building2 size={14} />}
239+ title="The workspace"
240+ amount={limit ? (limitMicros != null ? `${money(limitMicros)} a month` : "No limit") : <span className="text-faint">Unavailable</span>}
241+ spent={limit && workspaceFigures ? (limit.spentMicros ?? null) : null}
242+ budget={limitMicros}
243+ atLimit={AT_LIMIT.workspace(limit?.pauseAtLimit ?? true)}
244+ action={
245+ <Link to={`/${slug}/-/billing`} className={EDIT}>
246+ {owner ? "Change" : "Billing"}
247+ </Link>
248+ }
249+ >
250+ {limit?.defaultSpendLimit && <p className="mt-2 ml-10 text-xs text-faint">Automatic until owners set one: $200, or twice last month&apos;s spend, whichever is more.</p>}
251+ </Level>
252+ <Level
253+ icon={<Sparkles size={14} />}
254+ title="All agents together"
255+ amount={policy ? (policy.monthly_micros != null ? `${money(policy.monthly_micros)} a month` : "No budget of its own") : <span className="text-faint">Unavailable</span>}
256+ spent={workspaceFigures ? budgets.agentsMonthMicros : null}
257+ budget={policy?.monthly_micros ?? null}
258+ atLimit={AT_LIMIT.agents}
259+ action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<button type="button" className={EDIT}>Change</button>} /> : null}
260+ />
261+ <Level
262+ icon={<UserRound size={14} />}
263+ title="Each person"
264+ amount={policy ? (policy.person_monthly_micros != null ? `${money(policy.person_monthly_micros)} a month each` : "No budget per person") : <span className="text-faint">Unavailable</span>}
265+ spent={!owner && mine ? mine.spent_micros : null}
266+ budget={!owner && mine ? mine.monthly_micros : null}
267+ atLimit={AT_LIMIT.person}
268+ action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<button type="button" className={EDIT}>Change</button>} /> : null}
269+ >
270+ <p className="mt-2 ml-10 text-xs text-faint">What agents spend on work a person asks for: their chats with agents and the sessions they start.</p>
271+ {owner && people && <PeopleBudgets people={people.people} defaultMicros={people.default_micros} action={action} />}
272+ </Level>
273+ <Level
274+ icon={<Sparkles size={14} />}
275+ title="Each agent"
276+ amount={policy ? (policy.default_agent_monthly_micros != null ? `${money(policy.default_agent_monthly_micros)} a month for a new agent` : "Set on each agent") : <span className="text-faint">Unavailable</span>}
277+ atLimit={AT_LIMIT.agent}
278+ >
279+ {agents && agents.length > 0 && <AgentBudgets slug={slug} agents={agents} />}
280+ </Level>
281+ <Level
282+ icon={<ReceiptText size={14} />}
283+ title="Each task"
284+ amount={policy ? `${money(policy.default_session_micros)} a session` : <span className="text-faint">Unavailable</span>}
285+ atLimit={AT_LIMIT.session}
286+ action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<button type="button" className={EDIT}>Change</button>} /> : null}
287+ >
288+ <dl className="mt-3 ml-10 grid grid-cols-1 gap-x-6 gap-y-2 text-xs sm:grid-cols-3">
289+ <Fact label="A session" value={policy ? money(policy.default_session_micros) : "—"} hint="Unless its agent's own per-task cap is lower." />
290+ <Fact label="One run of the plan" value={caps ? money(caps.runMicros) : "—"} hint="Set under Caps on agents, on Billing." />
291+ <Fact label="Agents on one issue" value={caps ? money(caps.issueMicros) : "—"} hint="Every run on the issue together." />
292+ </dl>
293+ </Level>
294+ </ol>
295+ );
296+}
297+
298+function Fact({ label, value, hint }: { label: string; value: string; hint: string }) {
299+ return (
300+ <div>
301+ <dt className="text-faint">{label}</dt>
302+ <dd className="mt-0.5 text-sm tabular-nums">{value}</dd>
303+ <dd className="text-faint">{hint}</dd>
304+ </div>
305+ );
306+}
307+
308+/** Each agent against its own monthly budget, a link to its profile. */
309+function AgentBudgets({ slug, agents }: { slug: string; agents: AgentBudgetRow[] }) {
310+ const [all, setAll] = useState(false);
311+ const shown = all ? agents : agents.slice(0, 5);
312+ return (
313+ <ul className="mt-3 ml-10 space-y-2.5">
314+ {shown.map((agent) => (
315+ <li key={agent.id} className="flex items-center gap-2.5 text-sm">
316+ <AgentAvatar agent={{ handle: agent.handle, avatar_seed: agent.avatar_seed }} size={20} />
317+ <Link to={`/${slug}/-/agents/${agent.handle}/spend`} className="min-w-0 grow truncate hover:underline">
318+ {agent.display_name}
319+ </Link>
320+ <span className="shrink-0 text-xs text-faint tabular-nums">
321+ {money(agent.spent_month_micros)}
322+ {agent.budget.monthly_micros != null ? ` of ${money(agent.budget.monthly_micros)}` : " · no cap"}
323+ </span>
324+ <span className="hidden w-24 shrink-0 sm:block">
325+ {agent.budget.monthly_micros != null && <Meter spent={agent.spent_month_micros} cap={agent.budget.monthly_micros} label={`${agent.display_name}: spent of its budget`} size="sm" />}
326+ </span>
327+ </li>
328+ ))}
329+ {agents.length > 5 && (
330+ <li>
331+ <button type="button" onClick={() => setAll((v) => !v)} className="text-xs text-accent hover:underline">
332+ {all ? "Show fewer" : `Show all ${agents.length}`}
333+ </button>
334+ </li>
335+ )}
336+ </ul>
337+ );
338+}
339+
340+/** Each person who has a budget of their own or spent this month, for owners, with the way to set one. */
341+function PeopleBudgets({ people, defaultMicros, action }: { people: PersonBudget[]; defaultMicros: number | null; action: string }) {
342+ return (
343+ <div className="mt-3 ml-10">
344+ {people.length > 0 && (
345+ <ul className="space-y-2.5">
346+ {people.slice(0, 12).map((person) => (
347+ <li key={person.username} className="flex items-center gap-2.5 text-sm">
348+ <span className="min-w-0 grow truncate">
349+ @{person.username}
350+ {person.own && <span className="ml-1.5 text-xs text-faint">own budget</span>}
351+ </span>
352+ <span className="shrink-0 text-xs text-faint tabular-nums">
353+ {money(person.spent_micros)}
354+ {person.monthly_micros != null ? ` of ${money(person.monthly_micros)}` : " · no budget"}
355+ </span>
356+ <span className="hidden w-24 shrink-0 sm:block">
357+ {person.monthly_micros != null && <Meter spent={person.spent_micros} cap={person.monthly_micros} label={`@${person.username}: spent of their budget`} size="sm" />}
358+ </span>
359+ <PersonDialog action={action} person={person} defaultMicros={defaultMicros} trigger={<button type="button" className="shrink-0 text-xs text-accent hover:underline">Set</button>} />
360+ </li>
361+ ))}
362+ </ul>
363+ )}
364+ <div className="mt-2.5">
365+ <PersonDialog action={action} person={null} defaultMicros={defaultMicros} trigger={<button type="button" className="text-xs text-accent hover:underline">Give someone their own budget</button>} />
366+ </div>
367+ </div>
368+ );
369+}
370+
371+/** The workspace's agent policy: every agent together, each person, a new agent, a session. Owners only. */
372+function PolicyDialog({ policy, action, trigger }: { policy: AgentPolicy; action: string; trigger: ReactNode }) {
373+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher("spend-policy");
374+ const id = useId();
375+ return (
376+ <Dialog open={open} onOpenChange={setOpen}>
377+ <DialogTrigger asChild>{trigger}</DialogTrigger>
378+ <DialogContent>
379+ <DialogHeader>
380+ <DialogTitle>Agent budgets</DialogTitle>
381+ <DialogDescription>Monthly, reset on the 1st (UTC). The workspace&apos;s spend limit sits above all of them. Leave a budget empty for none.</DialogDescription>
382+ </DialogHeader>
383+ <fetcher.Form method="post" action={action} className="grid gap-5">
384+ <input type="hidden" name="intent" value="policy" />
385+ <Field>
386+ <FieldLabel htmlFor={`${id}-monthly`}>All agents together</FieldLabel>
387+ <DollarsInput id={`${id}-monthly`} name="monthly" defaultValue={dollarsField(policy.monthly_micros)} placeholder="No budget of its own" />
388+ </Field>
389+ <Field>
390+ <FieldLabel htmlFor={`${id}-person`}>Each person</FieldLabel>
391+ <DollarsInput id={`${id}-person`} name="person" defaultValue={dollarsField(policy.person_monthly_micros)} placeholder="No budget per person" />
392+ <FieldDescription>What agents may spend on the work one person asks for. Give someone more or less from their row.</FieldDescription>
393+ </Field>
394+ <Field>
395+ <FieldLabel htmlFor={`${id}-agent`}>A new agent</FieldLabel>
396+ <DollarsInput id={`${id}-agent`} name="default_agent" defaultValue={dollarsField(policy.default_agent_monthly_micros)} placeholder="None" />
397+ <FieldDescription>Filled in when an agent is hired; change each one on its profile.</FieldDescription>
398+ </Field>
399+ <Field>
400+ <FieldLabel htmlFor={`${id}-session`}>A session</FieldLabel>
401+ <DollarsInput id={`${id}-session`} name="default_session" defaultValue={dollarsField(policy.default_session_micros)} required />
402+ <FieldDescription>Between $0.10 and $500. A session stops here and asks before spending more.</FieldDescription>
403+ </Field>
404+ <FieldError>{error}</FieldError>
405+ <DialogFooter>
406+ <button type="button" className={BUTTONS.QUIET} onClick={() => setOpen(false)}>
407+ Cancel
408+ </button>
409+ <button type="submit" className={BUTTONS.PRIMARY} disabled={busy}>
410+ {busy ? "Saving…" : "Save budgets"}
411+ </button>
412+ </DialogFooter>
413+ </fetcher.Form>
414+ </DialogContent>
415+ </Dialog>
416+ );
417+}
418+
419+/** One person's own budget: an amount, none at all, or back to the default. Owners only. */
420+function PersonDialog({ action, person, defaultMicros, trigger }: { action: string; person: PersonBudget | null; defaultMicros: number | null; trigger: ReactNode }) {
421+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`spend-person-${person?.username ?? "new"}`);
422+ const id = useId();
423+ return (
424+ <Dialog open={open} onOpenChange={setOpen}>
425+ <DialogTrigger asChild>{trigger}</DialogTrigger>
426+ <DialogContent>
427+ <DialogHeader>
428+ <DialogTitle>{person ? `@${person.username}'s budget` : "A person's own budget"}</DialogTitle>
429+ <DialogDescription>
430+ What agents may spend on work this person asks for each month, in place of the default
431+ {defaultMicros != null ? ` of ${money(defaultMicros)}` : " (none now)"}. 0 means no budget at all.
432+ </DialogDescription>
433+ </DialogHeader>
434+ <fetcher.Form method="post" action={action} className="grid gap-5">
435+ <input type="hidden" name="intent" value="person" />
436+ {person ? (
437+ <input type="hidden" name="username" value={person.username} />
438+ ) : (
439+ <Field>
440+ <FieldLabel htmlFor={`${id}-username`}>Username</FieldLabel>
441+ <Input id={`${id}-username`} name="username" required autoComplete="off" placeholder="ana" />
442+ </Field>
443+ )}
444+ <Field>
445+ <FieldLabel htmlFor={`${id}-amount`}>Monthly budget</FieldLabel>
446+ <DollarsInput id={`${id}-amount`} name="amount" defaultValue={person?.own ? dollarsField(person.monthly_micros ?? 0) : ""} placeholder="The default" />
447+ <FieldDescription>Empty puts them back on the default.</FieldDescription>
448+ </Field>
449+ <FieldError>{error}</FieldError>
450+ <DialogFooter>
451+ <button type="button" className={BUTTONS.QUIET} onClick={() => setOpen(false)}>
452+ Cancel
453+ </button>
454+ <button type="submit" className={BUTTONS.PRIMARY} disabled={busy}>
455+ {busy ? "Saving…" : "Save"}
456+ </button>
457+ </DialogFooter>
458+ </fetcher.Form>
459+ </DialogContent>
460+ </Dialog>
461+ );
462+}
463+
464+// ── Receipts ─────────────────────────────────────────────────────────────
465+
466+/** Where a task's receipt is. */
467+export function receiptHref(slug: string, session: Pick<AgentSession, "id">): string {
468+ return `/${slug}/-/spend/receipts/${session.id}`;
469+}
470+
471+/** The costliest tasks, each a link to its receipt. */
472+export function TaskList({ slug, sessions }: { slug: string; sessions: AgentSession[] }) {
473+ if (sessions.length === 0) return <p className="px-4 py-6 text-center text-sm text-faint">No tasks in this period.</p>;
474+ return (
475+ <ul className="divide-y divide-line/60">
476+ {sessions.map((session) => (
477+ <li key={session.id} className="group relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
478+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={24} />
479+ <div className="min-w-0 grow">
480+ <Link to={receiptHref(slug, session)} className="block min-w-0 truncate text-sm font-medium after:absolute after:inset-0 hover:underline">
481+ {session.visible ? session.title || "Untitled session" : <PrivateTitle />}
482+ </Link>
483+ <p className="mt-0.5 truncate text-xs text-faint">
484+ {session.agent_name}
485+ {session.visible && session.asked_by_username ? ` · for @${session.asked_by_username}` : ""} · {stepsLine(session)} · <TimeAgo at={session.created_at} />
486+ </p>
487+ </div>
488+ <span className="shrink-0 text-sm tabular-nums">{money(session.charged_micros)}</span>
489+ <ArrowRight size={14} className="shrink-0 text-faint group-hover:text-muted" aria-hidden="true" />
490+ </li>
491+ ))}
492+ </ul>
493+ );
494+}
495+
496+// ── Pricing ──────────────────────────────────────────────────────────────
497+
498+/** How g1t prices what it runs, from the price book. */
499+export function PricingCard({ pricing }: { pricing: Pricing | null }) {
500+ if (!pricing) return <p className="rounded-xl border border-line bg-surface px-4 py-6 text-center text-sm text-muted">Prices couldn&apos;t be read right now.</p>;
501+ const rows: { icon: ReactNode; title: string; value: string; about: string }[] = [
502+ {
503+ icon: <Cpu size={15} />,
504+ title: "Models",
505+ value: pricing.modelMarkupPercent > 0 ? `Provider price + ${pricing.modelMarkupPercent}%` : "Provider price",
506+ about: pricing.modelMarkupPercent > 0 ? "What the model provider charges, with the markup shown." : "What the model provider charges, with no markup.",
507+ },
508+ {
509+ icon: <Sparkles size={15} />,
510+ title: "Agent rate",
511+ value: pricing.agentRateMicros != null ? `${money(pricing.agentRateMicros)} per million tokens` : "None",
512+ about: "For context, memory, routing and orchestration around each model call, on every token an agent's run uses.",
513+ },
514+ {
515+ icon: <Coins size={15} />,
516+ title: "Everything g1t runs",
517+ value: pricing.markup ? `At cost + ${markupLabel(pricing.markup)}` : "At cost",
518+ about: "Sandboxes, builds, hosting, storage and search, at what they cost g1t.",
519+ },
520+ {
521+ icon: <KeyRound size={15} />,
522+ title: "Your own model keys",
523+ value: pricing.agentRateOwnMicros != null ? `${money(pricing.agentRateOwnMicros)} per million tokens` : "Agent rate only",
524+ about: "Your provider bills the model; only the agent rate is charged here. Local models the same.",
525+ },
526+ { icon: <Building2 size={15} />, title: "Your own runners", value: "$0", about: "Time on your own hardware is never charged." },
527+ { icon: <Users size={15} />, title: "People", value: "No seats", about: "Invite everyone; people chatting costs nothing." },
528+ ];
529+ return (
530+ <ul className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-2 lg:grid-cols-3">
531+ {rows.map((row) => (
532+ <li key={row.title} className="bg-surface p-4">
533+ <p className="flex items-center gap-2 text-xs text-muted">
534+ <span className="text-faint">{row.icon}</span>
535+ {row.title}
536+ </p>
537+ <p className="mt-1 text-sm font-medium">{row.value}</p>
538+ <p className="mt-0.5 text-xs text-faint">{row.about}</p>
539+ </li>
540+ ))}
541+ </ul>
542+ );
543+}
544+
545+// ── The top bar's pill ───────────────────────────────────────────────────
546+
547+const SCOPE_KEY = "g1t_spend_pill";
548+
549+function storedScope(): SpendScope {
550+ try {
551+ return window.localStorage.getItem(SCOPE_KEY) === "workspace" ? "workspace" : "me";
552+ } catch {
553+ return "me";
554+ }
555+}
556+
557+/**
558+ * Your spend this month in the top bar: what agents did for you against
559+ * your budget, or, for owners and billing managers who switch it, what the
560+ * workspace was charged against its spend limit. A click opens where it
561+ * went. Read after the page draws, so it adds nothing to the page's time.
562+ */
563+export function SpendPill({ slug, mayWorkspace }: { slug: string; mayWorkspace: boolean }) {
564+ const pill = useFetcher<PillData>({ key: `spend-pill:${slug}` });
565+ const [scope, setScope] = useState<SpendScope>("me");
566+ const [open, setOpen] = useState(false);
567+ useEffect(() => {
568+ setScope(mayWorkspace ? storedScope() : "me");
569+ pill.load(`/${slug}/-/spend/pill`);
570+ // eslint-disable-next-line react-hooks/exhaustive-deps
571+ }, [slug, mayWorkspace]);
572+ useEffect(() => {
573+ if (open) pill.load(`/${slug}/-/spend/pill`);
574+ // eslint-disable-next-line react-hooks/exhaustive-deps
575+ }, [open]);
576+ const choose = (next: SpendScope) => {
577+ setScope(next);
578+ try {
579+ window.localStorage.setItem(SCOPE_KEY, next);
580+ } catch {
581+ // Kept for this page only.
582+ }
583+ };
584+ const data = pill.data;
585+ const ws = scope === "workspace" ? data?.workspace : null;
586+ const spent = ws ? ws.spentMicros : (data?.me?.spentMicros ?? null);
587+ const budget = ws ? ws.limitMicros : (data?.me?.budgetMicros ?? null);
588+ const share = spent != null ? shareOfBudget(spent, budget) : null;
589+ const label = spent == null ? (data ? "Spend" : "") : money(spent);
590+ return (
591+ <Popover open={open} onOpenChange={setOpen}>
592+ <PopoverTrigger
593+ aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`}
594+ className="flex h-8 items-center gap-2 rounded-md border border-line px-2.5 text-[0.8125rem] text-fg/90 tabular-nums transition-colors outline-none hover:border-line-strong hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised"
595+ >
596+ {ws ? <Building2 size={14} className="shrink-0 text-faint" /> : <Coins size={14} className="shrink-0 text-faint" />}
597+ {data ? <span>{label}</span> : <Skeleton className="h-3 w-10" />}
598+ {share != null && (
599+ <span className="hidden w-9 lg:block">
600+ <Meter spent={spent!} cap={budget} label="Spent of the budget" size="sm" />
601+ </span>
602+ )}
603+ </PopoverTrigger>
604+ <PopoverContent align="end" className="w-80 p-0">
605+ <SpendPopover slug={slug} data={data ?? null} scope={scope} mayWorkspace={mayWorkspace} onScope={choose} onClose={() => setOpen(false)} />
606+ </PopoverContent>
607+ </Popover>
608+ );
609+}
610+
611+function SpendPopover({
612+ slug,
613+ data,
614+ scope,
615+ mayWorkspace,
616+ onScope,
617+ onClose,
618+}: {
619+ slug: string;
620+ data: PillData | null;
621+ scope: SpendScope;
622+ mayWorkspace: boolean;
623+ onScope: (scope: SpendScope) => void;
624+ onClose: () => void;
625+}) {
626+ const month = data ? monthName(data.month).split(" ")[0] : "";
627+ const ws = scope === "workspace" ? data?.workspace : null;
628+ return (
629+ <div>
630+ <div className="flex items-center gap-1 border-b border-line p-1.5">
631+ {(["me", "workspace"] as const).map((key) =>
632+ key === "workspace" && !mayWorkspace ? (
633+ <Hint key={key} label="The workspace's spend is for owners and billing managers.">
634+ <span tabIndex={0} className="flex h-7 grow cursor-default items-center justify-center rounded-md text-xs text-faint">
635+ Workspace
636+ </span>
637+ </Hint>
638+ ) : (
639+ <button
640+ key={key}
641+ type="button"
642+ aria-pressed={scope === key}
643+ onClick={() => onScope(key)}
644+ className={cn("h-7 grow rounded-md text-xs transition-colors", scope === key ? "bg-surface text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg")}
645+ >
646+ {key === "me" ? "You" : "Workspace"}
647+ </button>
648+ ),
649+ )}
650+ </div>
651+ {!data ? (
652+ <div className="space-y-2 p-4">
653+ <Skeleton className="h-3 w-32" />
654+ <Skeleton className="h-6 w-24" />
655+ <Skeleton className="h-3 w-full" />
656+ </div>
657+ ) : ws ? (
658+ <div className="p-4">
659+ <p className="text-xs text-muted">The workspace · {month}, charged</p>
660+ <p className="mt-1 text-xl font-semibold tabular-nums">
661+ {ws.spentMicros != null ? money(ws.spentMicros) : "—"}
662+ {ws.limitMicros != null && <span className="ml-1.5 text-sm font-normal text-muted">of {money(ws.limitMicros)}</span>}
663+ </p>
664+ {ws.spentMicros != null && ws.limitMicros != null && <Meter spent={ws.spentMicros} cap={ws.limitMicros} label="Charged of the spend limit" size="sm" className="mt-2" />}
665+ <p className="mt-3 text-xs text-muted">Agents this month{ws.agentsMicros != null ? `: ${money(ws.agentsMicros)}` : ""}</p>
666+ <PopoverSlices slices={ws.byAgent} />
667+ </div>
668+ ) : data.me ? (
669+ <div className="p-4">
670+ <p className="text-xs text-muted">You · {month}, agents working for you included</p>
671+ <p className="mt-1 text-xl font-semibold tabular-nums">
672+ {money(data.me.spentMicros)}
673+ {data.me.budgetMicros != null && <span className="ml-1.5 text-sm font-normal text-muted">of {money(data.me.budgetMicros)}</span>}
674+ </p>
675+ {data.me.budgetMicros != null ? (
676+ <Meter spent={data.me.spentMicros} cap={data.me.budgetMicros} label="Spent of your budget" size="sm" className="mt-2" />
677+ ) : (
678+ <p className="mt-0.5 text-xs text-faint">No budget of your own; the workspace&apos;s limits apply.</p>
679+ )}
680+ <PopoverSlices slices={data.me.byKind} />
681+ {data.me.byAgent.length > 0 && (
682+ <p className="mt-2 truncate text-xs text-faint">
683+ {data.me.byAgent
684+ .slice(0, 3)
685+ .map((s) => `${s.label.replace(/ \(@[^)]+\)$/, "")} ${money(s.micros)}`)
686+ .join(" · ")}
687+ </p>
688+ )}
689+ </div>
690+ ) : (
691+ <p className="p-4 text-sm text-muted">Your spend couldn&apos;t be read right now.</p>
692+ )}
693+ <div className="border-t border-line p-1.5">
694+ <Link
695+ to={`/${slug}/-/spend${scope === "workspace" ? "" : "?scope=me"}`}
696+ onClick={onClose}
697+ className="flex h-8 items-center justify-between rounded-md px-2.5 text-sm text-fg/90 hover:bg-surface hover:text-fg"
698+ >
699+ {scope === "workspace" ? "Open workspace spend" : "Your spend and budget"}
700+ <ArrowRight size={14} className="text-faint" />
701+ </Link>
702+ </div>
703+ </div>
704+ );
705+}
706+
707+function PopoverSlices({ slices }: { slices: { key: string; label: string; micros: number }[] }) {
708+ const shown = slices.filter((s) => s.micros > 0).slice(0, 4);
709+ if (shown.length === 0) return <p className="mt-2 text-xs text-faint">Nothing spent yet this month.</p>;
710+ return (
711+ <ul className="mt-2 space-y-1">
712+ {shown.map((s) => (
713+ <li key={s.key || "none"} className="flex items-center justify-between gap-3 text-sm">
714+ <span className="min-w-0 truncate text-fg-soft">{s.label}</span>
715+ <span className="shrink-0 tabular-nums">{money(s.micros)}</span>
716+ </li>
717+ ))}
718+ </ul>
719+ );
720+}
+1−1
118118 "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " +
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121− "stargazers starred commit_checks shortcuts"
121+ "stargazers starred commit_checks shortcuts spend person_budgets usage_report"
122122 ).split(" "),
123123 );
124124
+114−0
1+/**
2+ * Spend's reads: each part from the service that owns it, each on its own
3+ * so one that fails leaves only its part saying so. Billing has the
4+ * workspace's money (usage at price, the spend limit, caps, the price
5+ * book); the agents service has where agent work went (by agent, person,
6+ * channel, model, kind) and the budgets below the workspace's (its agents
7+ * together, each person, each agent, each session). The shaping is in
8+ * ./spend.ts.
9+ */
10+import type { AgentPolicy, AgentSpendBreakdown, Limit, PersonBudgets, SpendPeriod, UsageReport, User, WorkspaceAgent } from "@g1t/contracts";
11+
12+import { type Pricing, type SpendScope, pricingOf, spanFor } from "./spend";
13+import { billing, workspaceAgents } from "./services.server";
14+
15+const warn = (what: string) => (error: unknown) => {
16+ console.warn(`spend: ${what} failed`, error);
17+ return null;
18+};
19+
20+const value = <T>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result && result.ok ? result.value : null);
21+
22+/** Where agent work went for the scope and period: everyone's, or only what the viewer asked for. */
23+export function loadBreakdown(viewer: User, slug: string, scope: SpendScope, period: SpendPeriod): Promise<AgentSpendBreakdown | null> {
24+ return workspaceAgents
25+ .spend(slug, viewer, null, { period, person: scope === "me" ? viewer.username.toLowerCase() : null })
26+ .then(value)
27+ .catch(warn("agents spend"));
28+}
29+
30+/** Everything the workspace used over the period, at price, by product and day. */
31+export function loadUsage(viewer: User, slug: string, period: SpendPeriod, now: Date): Promise<UsageReport | null> {
32+ const { from, until } = spanFor(period, now);
33+ return billing.usageReport(slug, viewer, { from, until }).then(value).catch(warn("usage report"));
34+}
35+
36+/** One agent's budget and month, as the budgets list shows it. */
37+export type AgentBudgetRow = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar_seed" | "budget" | "spent_month_micros" | "builtin">;
38+
39+/** Every level of budget, widest first, with what is spent against each this month. */
40+export type Budgets = {
41+ /** The workspace's spend limit (billing); null when billing did not answer or is off. */
42+ limit: Limit | null;
43+ /** The plan's caps on one run and one issue (billing). */
44+ caps: { runMicros: number; issueMicros: number } | null;
45+ /** The workspace's agent policy: every agent together, the per-person default, a new agent's, a session's. */
46+ policy: AgentPolicy | null;
47+ /** Every agent's spend this month, against the policy's budget. */
48+ agentsMonthMicros: number | null;
49+ people: PersonBudgets | null;
50+ agents: AgentBudgetRow[] | null;
51+};
52+
53+export async function loadBudgets(viewer: User, slug: string, monthBreakdown: Promise<AgentSpendBreakdown | null>): Promise<Budgets> {
54+ const [limit, entitlements, policy, people, agents, month] = await Promise.all([
55+ billing.limit(slug, viewer).then(value).catch(warn("limit")),
56+ billing.entitlements(slug).catch(warn("entitlements")),
57+ workspaceAgents.policy(slug, viewer).then(value).catch(warn("agent policy")),
58+ workspaceAgents.personBudgets(slug, viewer).then(value).catch(warn("person budgets")),
59+ workspaceAgents.list(slug, viewer).then(value).catch(warn("agents")),
60+ monthBreakdown,
61+ ]);
62+ return {
63+ limit,
64+ caps: entitlements ? { runMicros: entitlements.runCapMicros, issueMicros: entitlements.issueCapMicros } : null,
65+ policy,
66+ agentsMonthMicros: month?.total_micros ?? null,
67+ people,
68+ agents: agents
69+ ? agents
70+ .filter((a) => !a.archived_at)
71+ .map((a) => ({ id: a.id, handle: a.handle, display_name: a.display_name, avatar_seed: a.avatar_seed, budget: a.budget, spent_month_micros: a.spent_month_micros, builtin: a.builtin }))
72+ .sort((a, b) => Number(b.builtin) - Number(a.builtin) || b.spent_month_micros - a.spent_month_micros)
73+ : null,
74+ };
75+}
76+
77+/** What the price book says about pricing. */
78+export function loadPricing(): Promise<Pricing | null> {
79+ return billing
80+ .prices()
81+ .then(pricingOf)
82+ .catch(warn("prices"));
83+}
84+
85+/** The top bar's pill: the viewer's month, and the workspace's for someone who may see it. */
86+export type PillData = {
87+ month: string;
88+ me: { spentMicros: number; budgetMicros: number | null; byKind: AgentSpendBreakdown["by_kind"]; byAgent: AgentSpendBreakdown["by_agent"] } | null;
89+ workspace: { spentMicros: number | null; limitMicros: number | null; agentsMicros: number | null; byAgent: AgentSpendBreakdown["by_agent"] } | null;
90+};
91+
92+export async function loadPill(viewer: User, slug: string, mayWorkspace: boolean): Promise<PillData> {
93+ const me = viewer.username.toLowerCase();
94+ const [mine, people, everyone, limit] = await Promise.all([
95+ loadBreakdown(viewer, slug, "me", "month"),
96+ workspaceAgents.personBudgets(slug, viewer).then(value).catch(warn("person budgets")),
97+ mayWorkspace ? loadBreakdown(viewer, slug, "workspace", "month") : Promise.resolve(null),
98+ mayWorkspace ? billing.limit(slug, viewer).then(value).catch(warn("limit")) : Promise.resolve(null),
99+ ]);
100+ const own = people?.people.find((p) => p.username === me);
101+ const budget = own ? own.monthly_micros : (people?.default_micros ?? null);
102+ return {
103+ month: (mine ?? everyone)?.period ?? new Date().toISOString().slice(0, 7),
104+ me: mine ? { spentMicros: mine.total_micros, budgetMicros: budget, byKind: mine.by_kind, byAgent: mine.by_agent } : null,
105+ workspace: mayWorkspace
106+ ? {
107+ spentMicros: limit?.spentMicros ?? null,
108+ limitMicros: limit ? (limit.spendLimitMicros ?? limit.ceilingMicros) : null,
109+ agentsMicros: everyone?.total_micros ?? null,
110+ byAgent: everyone?.by_agent ?? [],
111+ }
112+ : null,
113+ };
114+}
+89−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AgentSession, PriceBook } from "@g1t/contracts";
5+
6+import { daySeries, markupLabel, percentLabel, pricingOf, readPeriod, readScope, receiptOf, spanFor, tokenCount } from "./spend.ts";
7+
8+const OCT_8 = new Date("2026-10-08T15:00:00Z");
9+
10+test("a period's days match the agents service's, both ends included", () => {
11+ assert.deepEqual(spanFor("month", OCT_8), { from: "2026-10-01", until: "2026-10-08" });
12+ assert.deepEqual(spanFor("last_month", OCT_8), { from: "2026-09-01", until: "2026-09-30" });
13+ assert.deepEqual(spanFor("last_month", new Date("2026-01-15T00:00:00Z")), { from: "2025-12-01", until: "2025-12-31" });
14+ assert.deepEqual(spanFor("7d", OCT_8), { from: "2026-10-02", until: "2026-10-08" });
15+ assert.deepEqual(spanFor("30d", OCT_8), { from: "2026-09-09", until: "2026-10-08" });
16+});
17+
18+test("only someone who may see the workspace's spend gets it; anything else is their own", () => {
19+ assert.equal(readScope(null, true), "workspace");
20+ assert.equal(readScope("me", true), "me");
21+ assert.equal(readScope("workspace", false), "me");
22+ assert.equal(readPeriod("7d"), "7d");
23+ assert.equal(readPeriod("year"), "month");
24+});
25+
26+test("a day series has every day, products added together, zeros where nothing was spent", () => {
27+ const days = daySeries("2026-10-01", "2026-10-03", [
28+ { day: "2026-10-01", micros: 5 },
29+ { day: "2026-10-01", micros: 7 },
30+ { day: "2026-10-03", micros: 1 },
31+ ]);
32+ assert.deepEqual(days, [
33+ { day: "2026-10-01", micros: 12 },
34+ { day: "2026-10-02", micros: 0 },
35+ { day: "2026-10-03", micros: 1 },
36+ ]);
37+});
38+
39+test("pricing comes from the price book: the model markup, the run markup, the agent rate", () => {
40+ const book = {
41+ modelMarginPercent: 0,
42+ changes: [],
43+ prices: [
44+ { meter: "sandbox_second", costMicros: 10, markupPercent: 20, priceMicros: 12 },
45+ { meter: "build_second", costMicros: 10, markupPercent: 20, priceMicros: 12 },
46+ { meter: "agent_tokens", costMicros: 0, markupPercent: 0, priceMicros: 250_000 },
47+ { meter: "agent_tokens_own", costMicros: 0, markupPercent: 0, priceMicros: 250_000 },
48+ ],
49+ } as unknown as PriceBook;
50+ const pricing = pricingOf(book);
51+ assert.equal(pricing.modelMarkupPercent, 0);
52+ assert.deepEqual(pricing.markup, { min: 20, max: 20 });
53+ assert.equal(markupLabel(pricing.markup!), "20%");
54+ assert.equal(markupLabel({ min: 15, max: 20 }), "15–20%");
55+ assert.equal(pricing.agentRateMicros, 250_000);
56+});
57+
58+const session = (over: Partial<AgentSession>): AgentSession =>
59+ ({ id: "s", parent_id: null, root_id: "r", charged_micros: 0, cost_micros: 0, input_tokens: 0, output_tokens: 0, steps: 0, tool_calls: 0, created_at: "2026-10-08T00:00:00Z", ...over }) as AgentSession;
60+
61+test("a receipt gives the root its own share, children under their parents, and the tree's totals", () => {
62+ const tree = [
63+ session({ id: "r", charged_micros: 1_840_000, cost_micros: 1_000_000, input_tokens: 400_000, output_tokens: 30_000, steps: 6, tool_calls: 20 }),
64+ session({ id: "c1", parent_id: "r", charged_micros: 300_000, cost_micros: 200_000, input_tokens: 10_000, steps: 2, created_at: "2026-10-08T00:01:00Z" }),
65+ session({ id: "g1", parent_id: "c1", charged_micros: 40_000, cost_micros: 30_000, steps: 1, created_at: "2026-10-08T00:02:00Z" }),
66+ ];
67+ const receipt = receiptOf(tree, "r")!;
68+ assert.deepEqual(
69+ receipt.lines.map((l) => [l.session.id, l.depth, l.ownMicros]),
70+ [
71+ ["r", 0, 1_500_000],
72+ ["c1", 1, 300_000],
73+ ["g1", 2, 40_000],
74+ ],
75+ );
76+ assert.equal(receipt.chargedMicros, 1_840_000);
77+ assert.equal(receipt.providerMicros, 1_230_000);
78+ assert.equal(receipt.steps, 9);
79+ assert.equal(receiptOf([], "r"), null);
80+});
81+
82+test("token counts and percents read short", () => {
83+ assert.equal(tokenCount(830), "830");
84+ assert.equal(tokenCount(1_200), "1.2K");
85+ assert.equal(tokenCount(412_000), "412K");
86+ assert.equal(tokenCount(1_250_000), "1.3M");
87+ assert.equal(percentLabel(62, 100), "62%");
88+ assert.equal(percentLabel(62, null), "");
89+});
+204−0
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;
+1−0
150150 members: "people",
151151 teams: "people",
152152 workspace: "workspace",
153+ spend: "workspace",
153154 usage: "workspace",
154155 gateway: "workspace",
155156 billing: "workspace",
+4−0
119119 route("-/tokens/:id", "routes/workspace/token.tsx"),
120120 // Its rules for members' personal access tokens, and approving them.
121121 route("-/personal-access-tokens", "routes/workspace/personal-access-tokens.tsx"),
122+ // Spend, the front of its money: where it went, budgets, receipts; and the top bar's pill.
123+ route("-/spend", "routes/workspace/spend.tsx"),
124+ route("-/spend/pill", "routes/workspace/spend-pill.ts"),
125+ route("-/spend/receipts/:id", "routes/workspace/spend-receipt.tsx"),
122126 route("-/usage", "routes/workspace/usage.tsx"),
123127 route("-/gateway", "routes/workspace/gateway.tsx"),
124128 route("-/billing", "routes/workspace/billing.tsx"),
+17−0
1+import { data } from "react-router";
2+
3+import type { Route } from "./+types/spend-pill";
4+import { managesBilling, requireUser, roleIn } from "../../lib/session.server";
5+import { loadPill } from "../../lib/spend.server";
6+
7+/**
8+ * The top bar's spend pill, read after the page draws (components/spend.tsx
9+ * `SpendPill`): the viewer's month, and the workspace's for owners and
10+ * billing managers.
11+ */
12+export async function loader({ params, context, request }: Route.LoaderArgs) {
13+ const viewer = requireUser(context, request);
14+ const slug = params.owner.toLowerCase();
15+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
16+ return data(await loadPill(viewer, slug, managesBilling(viewer, slug)), { headers: { "Cache-Control": "private, no-store" } });
17+}
+154−0
1+import { ArrowLeft, CornerDownRight } from "lucide-react";
2+import type { CSSProperties } from "react";
3+import { Link, data } from "react-router";
4+
5+import type { Route } from "./+types/spend-receipt";
6+import { AgentAvatar } from "../../components/agent-avatar";
7+import { KindBadge, PrivateTitle, StatusChip, sessionHref } from "../../components/agents/parts";
8+import { whereLabel } from "../../components/agents/format";
9+import { TimeAgo } from "../../components/ui";
10+import { page } from "../../lib/meta";
11+import { requireUser, roleIn } from "../../lib/session.server";
12+import { loadPricing } from "../../lib/spend.server";
13+import { receiptOf, tokenCount } from "../../lib/spend";
14+import { workspaceAgents } from "../../lib/services.server";
15+import { money } from "../../lib/usage";
16+
17+export function meta({ params, ...args }: Route.MetaArgs) {
18+ return page(args, { title: `Receipt · ${params.owner} · g1t` });
19+}
20+
21+/** A task's receipt: its session tree from the agents service, and the price book's terms. */
22+export async function loader({ params, context, request }: Route.LoaderArgs) {
23+ const viewer = requireUser(context, request);
24+ const slug = params.owner.toLowerCase();
25+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
26+ const [detail, pricing] = await Promise.all([workspaceAgents.session(slug, params.id, viewer).catch(() => null), loadPricing()]);
27+ if (detail && !detail.ok && detail.error.code === "not_found") throw data(null, { status: 404 });
28+ const receipt = detail?.ok ? receiptOf(detail.value.tree, detail.value.session.root_id) : null;
29+ return { slug, receipt, pricing };
30+}
31+
32+/**
33+ * One task, itemized: each session of its tree (the agent's own, and every
34+ * helper and subagent it brought in) with its tokens, the model at the
35+ * provider's price and what it was charged; then the totals. A session in
36+ * a conversation you aren't in shows what it cost, not what it was about.
37+ */
38+export default function Receipt({ loaderData }: Route.ComponentProps) {
39+ const { slug, receipt, pricing } = loaderData;
40+ const back = (
41+ <Link to={`/${slug}/-/spend`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
42+ <ArrowLeft size={14} /> Spend
43+ </Link>
44+ );
45+ if (!receipt) {
46+ return (
47+ <div className="mx-auto w-full max-w-215 space-y-6 px-4 py-5 md:px-10 md:py-8">
48+ {back}
49+ <p className="rounded-xl border border-line bg-surface px-4 py-8 text-center text-sm text-muted">This receipt couldn&apos;t be read right now. Reload in a moment.</p>
50+ </div>
51+ );
52+ }
53+ const { root } = receipt;
54+ const g1tPart = Math.max(0, receipt.chargedMicros - receipt.providerMicros);
55+ return (
56+ <div className="mx-auto w-full max-w-215 space-y-6 px-4 py-5 md:px-10 md:py-8">
57+ {back}
58+ <header className="flex items-start gap-3">
59+ <AgentAvatar agent={{ handle: root.agent_handle, avatar_seed: root.agent_avatar_seed }} size={36} />
60+ <div className="min-w-0 grow">
61+ <p className="text-xs text-muted">Receipt</p>
62+ <h1 className="mt-0.5 text-xl leading-snug font-semibold tracking-tight">{root.visible ? root.title || "Untitled session" : <PrivateTitle />}</h1>
63+ <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint">
64+ <span className="text-muted">{root.agent_name}</span>
65+ {root.visible && root.asked_by_username && <span>· for @{root.asked_by_username}</span>}
66+ {root.visible && <span>· {whereLabel(root)}</span>}
67+ <span>·</span>
68+ <TimeAgo at={root.created_at} />
69+ <StatusChip status={root.status} className="ml-1" />
70+ </p>
71+ </div>
72+ <div className="shrink-0 text-right">
73+ <p className="text-xs text-muted">Total</p>
74+ <p className="text-2xl font-semibold tracking-tight tabular-nums">{money(receipt.chargedMicros)}</p>
75+ </div>
76+ </header>
77+
78+ <section aria-label="Sessions" className="overflow-hidden rounded-xl border border-line bg-surface">
79+ <div className="hidden grid-cols-[minmax(0,1fr)_9rem_6.5rem_5.5rem] gap-3 border-b border-line px-4 py-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase sm:grid">
80+ <span>Session</span>
81+ <span className="text-right">Tokens</span>
82+ <span className="text-right">Provider price</span>
83+ <span className="text-right">Charged</span>
84+ </div>
85+ <ul className="divide-y divide-line/60">
86+ {receipt.lines.map(({ session, depth, ownMicros }) => (
87+ <li key={session.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_9rem_6.5rem_5.5rem] sm:items-center">
88+ <div className="flex min-w-0 items-start gap-2" style={{ paddingLeft: `${depth * 1.25}rem` }}>
89+ {depth > 0 && <CornerDownRight size={13} className="mt-0.5 shrink-0 text-faint" aria-hidden="true" />}
90+ <div className="min-w-0">
91+ <p className="flex min-w-0 flex-wrap items-center gap-1.5 text-sm">
92+ {session.visible ? (
93+ <Link to={sessionHref(slug, session)} className="min-w-0 truncate hover:underline">
94+ {session.title || "Untitled session"}
95+ </Link>
96+ ) : (
97+ <PrivateTitle />
98+ )}
99+ <KindBadge kind={session.kind} subagent={session.subagent} />
100+ </p>
101+ <p className="mt-0.5 truncate text-xs text-faint">
102+ {session.agent_name}
103+ {session.model ? ` · ${session.model}` : ""} · {session.steps} {session.steps === 1 ? "step" : "steps"} · {session.tool_calls} {session.tool_calls === 1 ? "tool" : "tools"}
104+ </p>
105+ </div>
106+ </div>
107+ <span className="text-right text-sm tabular-nums sm:hidden">{money(ownMicros)}</span>
108+ <span className="col-span-2 text-xs text-faint tabular-nums sm:col-span-1 sm:text-right sm:text-sm sm:text-muted max-sm:pl-(--indent)" style={{ "--indent": `${depth * 1.25 + (depth ? 1.3 : 0)}rem` } as CSSProperties}>
109+ <span className="sm:hidden">Tokens </span>
110+ {tokenCount(session.input_tokens)} in · {tokenCount(session.output_tokens)} out
111+ </span>
112+ <span className="hidden text-right text-sm text-muted tabular-nums sm:block">{money(session.cost_micros ?? 0)}</span>
113+ <span className="hidden text-right text-sm tabular-nums sm:block">{money(ownMicros)}</span>
114+ </li>
115+ ))}
116+ </ul>
117+ </section>
118+
119+ <section aria-label="Totals" className="rounded-xl border border-line bg-surface p-4">
120+ <dl className="space-y-2 text-sm">
121+ <Row
122+ label={receipt.chargedMicros < receipt.providerMicros ? "Models, billed by your own provider, not here" : "Models, at the provider's price"}
123+ value={money(receipt.providerMicros)}
124+ />
125+ <Row
126+ label={
127+ pricing?.agentRateMicros != null
128+ ? `g1t's agent rate, ${money(pricing.agentRateMicros)} per million tokens${pricing.modelMarkupPercent > 0 ? `, and the ${pricing.modelMarkupPercent}% model markup` : ""}`
129+ : "g1t's part"
130+ }
131+ value={money(g1tPart)}
132+ />
133+ <div className="border-t border-line pt-2">
134+ <Row label="Total, as budgets count it" value={money(receipt.chargedMicros)} strong />
135+ </div>
136+ </dl>
137+ <p className="mt-3 text-xs text-faint">
138+ {tokenCount(receipt.inputTokens)} tokens in and {tokenCount(receipt.outputTokens)} out over {receipt.steps} steps and {receipt.toolCalls} tool calls. On your
139+ own model key, your provider bills the model and only the agent rate is charged here; time on your own runners is $0. What the workspace is charged after
140+ included usage and credit is on Billing.
141+ </p>
142+ </section>
143+ </div>
144+ );
145+}
146+
147+function Row({ label, value, strong = false }: { label: string; value: string; strong?: boolean }) {
148+ return (
149+ <div className="flex items-baseline justify-between gap-4">
150+ <dt className={strong ? "font-medium" : "text-muted"}>{label}</dt>
151+ <dd className={`shrink-0 tabular-nums ${strong ? "font-semibold" : ""}`}>{value}</dd>
152+ </div>
153+ );
154+}
+360−0
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&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+
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&apos;t answer. Reload in a moment.</p>
357+ </SlicePanel>
358+ );
359+}
360+
+69−7
347347 tool_calls: number;
348348 input_tokens: number;
349349 output_tokens: number;
350− /** At list price, what it counts against budgets. */
350+ /**
351+ * At list price, what it counts against budgets: the model at the
352+ * provider's price with billing's model margin, plus g1t's agent rate on
353+ * every token. A root session's includes everything its tree spent.
354+ */
351355 charged_micros: number;
356+ /** What its own steps' model answers cost at the provider's price, its tree's not included. */
357+ cost_micros: number;
352358 /** The most it may spend before someone approves more. */
353359 cap_micros: number | null;
354360 model: string | null;
514520 default_agent_monthly_micros: number | null;
515521 /** The cap one session starts with, unless its agent's per-task cap is lower. */
516522 default_session_micros: number;
523+ /**
524+ * What the agents working for one person (their replies and sessions,
525+ * asked for by that person) may spend together in a month, unless the
526+ * person has a budget of their own. Null: no budget per person.
527+ */
528+ person_monthly_micros: number | null;
529+};
530+
531+/** One person's budget: what agents working for them may spend in a month, and what they have. */
532+export type PersonBudget = {
533+ username: string;
534+ /** The budget that applies: their own, or the workspace's per-person default. Null: none. */
535+ monthly_micros: number | null;
536+ /** Whether it is their own, set by an owner, rather than the default. */
537+ own: boolean;
538+ /** What agents spent for them this month (UTC). */
539+ spent_micros: number;
517540 };
518541
542+/** Budgets per person: the default, and each person who has one of their own or has spent this month. */
543+export type PersonBudgets = {
544+ period: string;
545+ default_micros: number | null;
546+ /** Owners see everyone; anyone else sees only themselves. Most spent first. */
547+ people: PersonBudget[];
548+};
549+
519550 export type SpendSlice = { key: string; label: string; micros: number; count: number };
520551
521−/** Where an agent's (or every agent's) month went. */
552+/** Which days a breakdown covers: this month (the default), last month, or the last 7 or 30 days, in UTC. */
553+export type SpendPeriod = "month" | "last_month" | "7d" | "30d";
554+
555+/** Where an agent's (or every agent's) spend went over a period. */
522556 export type AgentSpendBreakdown = {
557+ /** `YYYY-MM` for a month; for a span of days, the month it ends in. */
523558 period: string;
559+ /** The span asked for, and its first and last day (`YYYY-MM-DD`, both included). */
560+ span: SpendPeriod;
561+ from: string;
562+ until: string;
563+ /** The one person it is about (work asked for by them), or null for everyone's. */
564+ person: string | null;
524565 total_micros: number;
525566 /** Chat replies, sessions, routines, helping colleagues. */
526567 by_kind: SpendSlice[];
529570 by_person: SpendSlice[];
530571 by_agent: SpendSlice[];
531572 by_team: SpendSlice[];
532− /** The costliest sessions this month. */
573+ /**
574+ * Where it was asked: a channel by id (labelled `#name`), direct
575+ * messages together (`dm`), and channels the viewer can't read together
576+ * (`private`).
577+ */
578+ by_channel: SpendSlice[];
579+ /** The costliest sessions in the period. */
533580 top_sessions: AgentSession[];
534− /** Spend by day this month. */
581+ /** Spend by day in the period. */
535582 days: { day: string; micros: number }[];
536583 };
537584
644691 deleteRoutine(workspace: string, handle: string, viewer: User, id: string): Promise<Result<null>>;
645692 /** Runs a routine now, as a session. */
646693 runRoutine(workspace: string, handle: string, viewer: User, id: string): Promise<Result<AgentSession>>;
647− /** Where the month went: one agent's, or every agent's. */
648− spend(workspace: string, viewer: User, handle?: string | null): Promise<Result<AgentSpendBreakdown>>;
694+ /**
695+ * Where the spend went: one agent's, or every agent's; this month unless
696+ * `period` says otherwise; for everyone, or only the work one `person`
697+ * (by username) asked for.
698+ */
699+ spend(workspace: string, viewer: User, handle?: string | null, options?: { period?: SpendPeriod | null; person?: string | null }): Promise<Result<AgentSpendBreakdown>>;
700+ /** Budgets per person this month: owners see everyone's, anyone else their own. */
701+ personBudgets(workspace: string, viewer: User): Promise<Result<PersonBudgets>>;
702+ /**
703+ * Gives one person a monthly budget of their own (`monthly_micros`; 0 for
704+ * no budget at all), or with null puts them back on the default. Owners only.
705+ */
706+ setPersonBudget(workspace: string, viewer: User, username: string, monthlyMicros: number | null): Promise<Result<PersonBudgets>>;
649707 activity(workspace: string, handle: string, viewer: User): Promise<Result<AgentActivity[]>>;
650708 versions(workspace: string, handle: string, viewer: User): Promise<Result<AgentVersion[]>>;
651709 /**
695753 saveRoutine: (workspace, handle, viewer, input, id) => call("save_routine", { workspace, handle, viewer, input, id: id ?? null }),
696754 deleteRoutine: (workspace, handle, viewer, id) => call("delete_routine", { workspace, handle, viewer, id }),
697755 runRoutine: (workspace, handle, viewer, id) => call("run_routine", { workspace, handle, viewer, id }),
698− spend: (workspace, viewer, handle) => call("spend", { workspace, viewer, handle: handle ?? null }),
756+ spend: (workspace, viewer, handle, options) =>
757+ call("spend", { workspace, viewer, handle: handle ?? null, period: options?.period ?? null, person: options?.person ?? null }),
758+ personBudgets: (workspace, viewer) => call("person_budgets", { workspace, viewer }),
759+ setPersonBudget: (workspace, viewer, username, monthlyMicros) =>
760+ call("set_person_budget", { workspace, viewer, username, monthly_micros: monthlyMicros }),
699761 activity: (workspace, handle, viewer) => call("activity", { workspace, handle, viewer }),
700762 versions: (workspace, handle, viewer) => call("versions", { workspace, handle, viewer }),
701763 cardAction: (input) => call("card_action", input),
+23−0
1+-- Budgets per person (docs.g1t.sh/guides/agent-budgets/): what the agents
2+-- working for one person, the replies and sessions that person asked for,
3+-- may spend together in a month. Budgets nest: the workspace's spend limit
4+-- (billing), the workspace's agent budget, a person's, an agent's, a task's.
5+
6+-- The budget every person has unless they have their own. Null: none.
7+ALTER TABLE agent_policies ADD COLUMN person_monthly_micros INTEGER;
8+
9+-- A person's own budget, set by an owner. 0: no budget at all, whatever
10+-- the default. No row: the default.
11+CREATE TABLE person_budgets (
12+ workspace_id TEXT NOT NULL,
13+ username TEXT NOT NULL,
14+ monthly_micros INTEGER NOT NULL,
15+ updated_by TEXT NOT NULL,
16+ updated_at TEXT NOT NULL,
17+ PRIMARY KEY (workspace_id, username)
18+);
19+
20+-- What a person's agents spent is summed from the replies and sessions they
21+-- asked for, so it counts from the first one, before any budget was set.
22+CREATE INDEX agent_replies_asker ON agent_replies (workspace_id, asked_by_username, created_at);
23+CREATE INDEX agent_sessions_asker ON agent_sessions (workspace_id, asked_by_username, created_at);
+27−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { agentStatus, budgetBlock, chargedMicros, costMicros, dayKey, monthKey, replyCapMicros, totalTokens } from "./budget.ts";
4+import { agentStatus, budgetBlock, chargedMicros, costMicros, dayKey, monthKey, personBlock, personLimit, replyCapMicros, spendSpan, totalTokens } from "./budget.ts";
55
66 const OCT_8 = new Date("2026-10-08T15:00:00Z");
77 const none = { monthly_micros: null, daily_micros: null, task_micros: null };
1212 assert.equal(dayKey(new Date("2026-10-31T23:59:59Z")), "2026-10-31");
1313 });
1414
15+test("a breakdown's span: this month by default, last month whole, or the last 7 or 30 days", () => {
16+ assert.deepEqual(spendSpan(null, OCT_8), { span: "month", from: "2026-10-01", until: "2026-10-08", period: "2026-10" });
17+ assert.deepEqual(spendSpan("nonsense", OCT_8), spendSpan("month", OCT_8));
18+ assert.deepEqual(spendSpan("last_month", OCT_8), { span: "last_month", from: "2026-09-01", until: "2026-09-30", period: "2026-09" });
19+ assert.deepEqual(spendSpan("last_month", new Date("2026-01-15T00:00:00Z")), { span: "last_month", from: "2025-12-01", until: "2025-12-31", period: "2025-12" });
20+ assert.deepEqual(spendSpan("7d", OCT_8), { span: "7d", from: "2026-10-02", until: "2026-10-08", period: "2026-10" });
21+ assert.deepEqual(spendSpan("30d", OCT_8), { span: "30d", from: "2026-09-09", until: "2026-10-08", period: "2026-10" });
22+});
23+
24+test("a person's own budget wins over the default; 0 of their own is no budget at all", () => {
25+ assert.equal(personLimit(null, null), null);
26+ assert.equal(personLimit(20_000_000, null), 20_000_000);
27+ assert.equal(personLimit(20_000_000, 50_000_000), 50_000_000);
28+ assert.equal(personLimit(20_000_000, 0), null);
29+ assert.equal(personLimit(0, null), null);
30+});
31+
32+test("a person's budget blocks at 100%, naming them and where it is raised", () => {
33+ assert.equal(personBlock("ana", null, 9e9, OCT_8), null);
34+ assert.equal(personBlock("ana", 10_000_000, 9_999_999, OCT_8), null);
35+ assert.equal(
36+ personBlock("ana", 10_000_000, 10_000_000, OCT_8),
37+ "@ana has used their agent budget for October. An owner can raise it under Workspace → Spend.",
38+ );
39+});
40+
1541 test("no caps, no block; a cap of zero is no cap", () => {
1642 assert.equal(budgetBlock(none, { month: 9e9, day: 9e9 }, OCT_8), null);
1743 assert.equal(budgetBlock({ ...none, monthly_micros: 0 }, { month: 9e9, day: 0 }, OCT_8), null);
+46−2
44 *
55 * Spend is limited at several levels (docs.g1t.sh/guides/agent-budgets/).
66 * The workspace's own limit and AI credit are billing's, checked by the
7− * compute gate. The agent's monthly and daily caps are checked here, from
8− * the spend this service rolls up per agent, before anything is reserved.
7+ * compute gate. The agent's monthly and daily caps, and the budget of the
8+ * person who asked, are checked here, before anything is reserved.
99 * Spend counts what a reply costs at price: the model at the provider's
1010 * price with billing's model margin, plus g1t's agent rate on every token.
1111 * The ledger (with comped terms and discounts) is billing's; the agent's
6868 return caps.length ? Math.max(1, Math.floor(Math.min(...caps))) : null;
6969 }
7070
71+export type Span = { span: "month" | "last_month" | "7d" | "30d"; from: string; until: string; period: string };
72+
73+/**
74+ * The days a spend breakdown covers, in UTC, both ends included: this
75+ * month to today (anything not asked for), last month whole, or the last
76+ * 7 or 30 days to today. `period` is the month it ends in.
77+ */
78+export function spendSpan(asked: unknown, now: Date): Span {
79+ const today = dayKey(now);
80+ const day = (offset: number) => dayKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offset)));
81+ switch (asked) {
82+ case "last_month": {
83+ const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0));
84+ const until = dayKey(end);
85+ return { span: "last_month", from: `${until.slice(0, 7)}-01`, until, period: until.slice(0, 7) };
86+ }
87+ case "7d":
88+ return { span: "7d", from: day(-6), until: today, period: monthKey(now) };
89+ case "30d":
90+ return { span: "30d", from: day(-29), until: today, period: monthKey(now) };
91+ default:
92+ return { span: "month", from: `${monthKey(now)}-01`, until: today, period: monthKey(now) };
93+ }
94+}
95+
96+/**
97+ * The budget that applies to one person: their own when an owner set one
98+ * (0 there means none at all), else the workspace's per-person default.
99+ * Null: no budget.
100+ */
101+export function personLimit(defaultMicros: number | null | undefined, own: number | null | undefined): number | null {
102+ if (typeof own === "number" && Number.isFinite(own)) return own > 0 ? Math.floor(own) : null;
103+ return capSet(defaultMicros) ? Math.floor(defaultMicros) : null;
104+}
105+
106+/**
107+ * Why work asked for by `username` may not start: what agents spent for
108+ * them this month has reached their budget. Null when it may.
109+ */
110+export function personBlock(username: string, limit: number | null, spent: number, now: Date): string | null {
111+ if (!capSet(limit) || spent < limit) return null;
112+ return `@${username} has used their agent budget for ${MONTHS[now.getUTCMonth()]}. An owner can raise it under Workspace → Spend.`;
113+}
114+
71115 /** The tokens one answer used, by kind. */
72116 export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number };
73117
+5−1
442442 case "run_routine":
443443 return Response.json(await service.view(args, (ctx) => views.runRoutineNow(ctx, args.handle, args.id)));
444444 case "spend":
445− return Response.json(await service.view(args, (ctx) => views.spend(ctx, args.handle ?? null)));
445+ return Response.json(await service.view(args, (ctx) => views.spend(ctx, args.handle ?? null, { period: args.period, person: args.person })));
446+ case "person_budgets":
447+ return Response.json(await service.view(args, (ctx) => views.personBudgetsView(ctx)));
448+ case "set_person_budget":
449+ return Response.json(await service.view(args, (ctx) => views.setPersonBudget(ctx, args.username, args.monthly_micros)));
446450 case "activity":
447451 return Response.json(await service.view(args, (ctx) => views.activity(ctx, args.handle)));
448452 case "versions":
+16−4
33 * through, so g1t meters and bills an agent's model work one way
44 * (docs.g1t.sh/guides/agent-budgets/).
55 *
6− * 1. The paying agent's own monthly and daily caps (`budget.ts`), and the
7− * workspace's budget for all its agents together (`policy.ts`).
6+ * 1. The paying agent's own monthly and daily caps (`budget.ts`), the
7+ * workspace's budget for all its agents together (`policy.ts`), and the
8+ * budget of the person who asked (`person-budget.ts`).
89 * 2. Whether the agent may use a model at all: g1t's hosted models are open
910 * as the runner decides (`hostedOpen`, `HOSTED_AGENT_WORKSPACES` and
1011 * billing's status), or the workspace's own provider; the agent's
2829
2930 import { hostedOpen } from "../../runner/src/hosted.ts";
3031 import { type AgentRouting as Policy, routingReader } from "../../runner/src/model-env.ts";
31−import { type Spent, type Tokens, budgetBlock, chargedMicros, replyCapMicros, totalTokens } from "./budget.ts";
32+import { type Spent, type Tokens, budgetBlock, chargedMicros, personBlock, personLimit, replyCapMicros, totalTokens } from "./budget.ts";
33+import { monthStart, ownBudget, personSpent } from "./person-budget.ts";
3234 import { BUILTIN_NO_MODEL } from "./orchestrator.ts";
3335 import { type PolicyRow, alertDue, markAlerted, policyBlock, readPolicy, workspaceSpendStatements } from "./policy.ts";
3436 import { dollars } from "./money.ts";
154156 start: ModelTier;
155157 /** Who asked, by username, for billing's record. */
156158 askerName: string | null;
159+ /** The person the work is for, by username: their budget counts it. Null for work no person asked for. */
160+ person?: string | null;
157161 /** What is left of a session's cap, so one step never overruns it. */
158162 leftMicros?: number | null;
159163 /** Agent tier limits narrower than the agent's own (a subagent's). */
181185 // 1. The paying agent's caps, and the workspace's budget for every agent.
182186 const payerDefinition = definitionOf(payer);
183187 const [month, day] = periods(now);
184− const [spentRows, policy] = await Promise.all([
188+ const asker = input.person ? input.person.toLowerCase() : null;
189+ const [spentRows, policy, ownCap] = await Promise.all([
185190 db
186191 .prepare("SELECT period, micros FROM agent_spend WHERE agent_id = ? AND period IN (?, ?)")
187192 .bind(payer.id, month, day)
188193 .all<{ period: string; micros: number }>(),
189194 readPolicy(db, row.workspace_id, month),
195+ asker ? ownBudget(db, row.workspace_id, asker) : Promise.resolve(null),
190196 ]);
191197 const spent: Spent = {
192198 month: spentRows.results.find((r) => r.period === month)?.micros ?? 0,
199205 }
200206 const pool = policyBlock(policy);
201207 if (pool) return { ok: false, reason: "workspace_agent_budget", message: pool };
208+ // The budget of the person the work is for, summed only when one applies.
209+ const personCap = asker ? personLimit(policy.person_monthly_micros, ownCap) : null;
210+ const personUsed = asker && personCap != null ? await personSpent(db, row.workspace_id, asker, monthStart(now)) : 0;
211+ const personStop = asker ? personBlock(asker, personCap, personUsed, now) : null;
212+ if (personStop) return { ok: false, reason: "person_budget", message: personStop };
202213
203214 // 2. Whether it may use a model at all.
204215 const definition = definitionOf(row);
279290 if (input.leftMicros != null) caps.push(Math.max(1, Math.floor(input.leftMicros)));
280291 const left = policy.monthly_micros ? policy.monthly_micros - policy.spent : null;
281292 if (left != null) caps.push(Math.max(1, left));
293+ if (personCap != null) caps.push(Math.max(1, personCap - personUsed));
282294 const cap = caps.filter((c): c is number => c != null);
283295 if (cap.length) await integrations.capModelSessions([await sha256Hex(session.token)], Math.min(...cap)).catch(() => 0);
284296
+91−0
1+/**
2+ * Budgets per person (docs.g1t.sh/guides/agent-budgets/): what the agents
3+ * working for one person, the replies and sessions that person asked for,
4+ * may spend together in a month. The default is the workspace policy's
5+ * `person_monthly_micros`; an owner can give anyone their own
6+ * (`person_budgets`, 0 for none at all). What a person's agents spent is
7+ * summed from the replies and the root sessions they asked for, the same
8+ * rows the spend breakdown reads, so the two always agree.
9+ */
10+import type { PersonBudget, PersonBudgets } from "@g1t/contracts";
11+
12+import { monthKey, personLimit } from "./budget.ts";
13+
14+/** The first instant of `now`'s month (UTC), as rows' `created_at` compare. */
15+export function monthStart(now: Date): string {
16+ return `${monthKey(now)}-01T00:00:00.000Z`;
17+}
18+
19+/** What agents spent for `username` since `from`: their replies, and their sessions with everything each brought in. */
20+export async function personSpent(db: D1Database, workspaceId: string, username: string, from: string): Promise<number> {
21+ const row = await db
22+ .prepare(
23+ `SELECT
24+ (SELECT COALESCE(SUM(charged_micros), 0) FROM agent_replies WHERE workspace_id = ?1 AND asked_by_username = ?2 AND created_at >= ?3)
25+ + (SELECT COALESCE(SUM(charged_micros), 0) FROM agent_sessions WHERE workspace_id = ?1 AND asked_by_username = ?2 AND created_at >= ?3 AND parent_id IS NULL)
26+ AS micros`,
27+ )
28+ .bind(workspaceId, username, from)
29+ .first<{ micros: number | null }>();
30+ return row?.micros ?? 0;
31+}
32+
33+/** A person's own budget, if an owner set one (0: none at all). */
34+export async function ownBudget(db: D1Database, workspaceId: string, username: string): Promise<number | null> {
35+ const row = await db
36+ .prepare("SELECT monthly_micros FROM person_budgets WHERE workspace_id = ? AND username = ?")
37+ .bind(workspaceId, username)
38+ .first<{ monthly_micros: number }>();
39+ return row ? row.monthly_micros : null;
40+}
41+
42+/**
43+ * Everyone's budget this month: each person with one of their own, and
44+ * each person agents spent for. `only` narrows it to one person.
45+ */
46+export async function personBudgets(db: D1Database, workspaceId: string, defaultMicros: number | null, now: Date, only: string | null): Promise<PersonBudgets> {
47+ const from = monthStart(now);
48+ const narrow = only ? " AND asked_by_username = ?3" : "";
49+ const binds = only ? [workspaceId, from, only] : [workspaceId, from];
50+ const [own, spent] = await Promise.all([
51+ db
52+ .prepare(`SELECT username, monthly_micros FROM person_budgets WHERE workspace_id = ?1${only ? " AND username = ?2" : ""}`)
53+ .bind(...(only ? [workspaceId, only] : [workspaceId]))
54+ .all<{ username: string; monthly_micros: number }>(),
55+ db
56+ .prepare(
57+ `SELECT asked_by_username AS username, SUM(micros) AS micros FROM (
58+ SELECT asked_by_username, charged_micros AS micros FROM agent_replies WHERE workspace_id = ?1 AND created_at >= ?2 AND asked_by_username IS NOT NULL${narrow}
59+ UNION ALL SELECT asked_by_username, charged_micros AS micros FROM agent_sessions WHERE workspace_id = ?1 AND created_at >= ?2 AND parent_id IS NULL AND asked_by_username IS NOT NULL${narrow}
60+ ) GROUP BY asked_by_username`,
61+ )
62+ .bind(...binds)
63+ .all<{ username: string; micros: number }>(),
64+ ]);
65+ const people = new Map<string, PersonBudget>();
66+ const entry = (username: string) => {
67+ const found = people.get(username);
68+ if (found) return found;
69+ const fresh: PersonBudget = { username, monthly_micros: personLimit(defaultMicros, null), own: false, spent_micros: 0 };
70+ people.set(username, fresh);
71+ return fresh;
72+ };
73+ for (const row of own.results) {
74+ const person = entry(row.username);
75+ person.own = true;
76+ person.monthly_micros = personLimit(defaultMicros, row.monthly_micros);
77+ }
78+ for (const row of spent.results) entry(row.username).spent_micros = row.micros ?? 0;
79+ if (only) entry(only);
80+ return {
81+ period: monthKey(now),
82+ default_micros: personLimit(defaultMicros, null),
83+ people: [...people.values()].sort((a, b) => b.spent_micros - a.spent_micros || a.username.localeCompare(b.username)),
84+ };
85+}
86+
87+/** A username as people type it: `@Ana` is `ana`. Null when it can't be one. */
88+export function cleanUsername(raw: unknown): string | null {
89+ const name = String(raw ?? "").trim().replace(/^@/, "").toLowerCase();
90+ return /^[a-z0-9][a-z0-9_-]{0,38}$/.test(name) ? name : null;
91+}
+12−4
22 * The workspace's say over all its agents together
33 * (docs.g1t.sh/guides/agent-budgets/): one monthly budget across every
44 * agent, the budget a new agent starts with, the cap a session starts with,
5− * and alerts at 75, 90 and 100% of the monthly budget. Owners set it. Pure
5+ * the budget each person's agents share, and alerts at 75, 90 and 100% of
6+ * the monthly budget. Owners set it. Pure
67 * apart from the statements it builds, so the rules are tested on their
78 * own.
89 */
2223 alerted: number;
2324 };
2425
25−export const DEFAULT_POLICY: AgentPolicy = { monthly_micros: null, default_agent_monthly_micros: null, default_session_micros: DEFAULT_SESSION_MICROS };
26+export const DEFAULT_POLICY: AgentPolicy = {
27+ monthly_micros: null,
28+ default_agent_monthly_micros: null,
29+ default_session_micros: DEFAULT_SESSION_MICROS,
30+ person_monthly_micros: null,
31+};
2632
2733 /** The workspace's policy and this month's spend across its agents. */
2834 export async function readPolicy(db: D1Database, workspaceId: string, month: string): Promise<PolicyRow> {
2935 const [policy, spend] = await Promise.all([
3036 db
31− .prepare("SELECT monthly_micros, default_agent_monthly_micros, default_session_micros FROM agent_policies WHERE workspace_id = ?")
37+ .prepare("SELECT monthly_micros, default_agent_monthly_micros, default_session_micros, person_monthly_micros FROM agent_policies WHERE workspace_id = ?")
3238 .bind(workspaceId)
3339 .first<AgentPolicy>(),
3440 db
4046 monthly_micros: positive(policy?.monthly_micros),
4147 default_agent_monthly_micros: positive(policy?.default_agent_monthly_micros),
4248 default_session_micros: positive(policy?.default_session_micros) ?? DEFAULT_SESSION_MICROS,
49+ person_monthly_micros: positive(policy?.person_monthly_micros),
4350 spent: spend?.micros ?? 0,
4451 alerted: spend?.alerted ?? 0,
4552 };
9299 */
93100 export function checkPolicy(current: AgentPolicy, changes: Partial<AgentPolicy>): { ok: true; value: AgentPolicy } | { ok: false; message: string } {
94101 const next = { ...current };
95− for (const key of ["monthly_micros", "default_agent_monthly_micros"] as const) {
102+ for (const key of ["monthly_micros", "default_agent_monthly_micros", "person_monthly_micros"] as const) {
96103 if (!(key in changes)) continue;
97104 const value = changes[key];
98105 if (value === null || value === 0) next[key] = null;
108115 }
109116 return { ok: true, value: next };
110117 }
118+
+1−1
354354 let posted: string | null = null;
355355 let spinOffs = 0;
356356
357− const done = await metered(env, { row, payer: row, slug, task: "reply", start, askerName }, async (model) => {
357+ const done = await metered(env, { row, payer: row, slug, task: "reply", start, askerName, person: delivery.asker?.username ?? null }, async (model) => {
358358 // What colleagues consulted along the way used: billed to this reply.
359359 const consulted = { tokens: NO_TOKENS, cost: 0 };
360360 let toolbox: ToolBox | null = null;
+2−0
176176 input_tokens: row.input_tokens,
177177 output_tokens: row.output_tokens,
178178 charged_micros: row.charged_micros,
179+ cost_micros: row.cost_micros,
179180 cap_micros: row.cap_micros,
180181 model: row.model,
181182 outputs: visible ? json<SessionOutput[]>(row.outputs, []) : [],
740741 task: "session",
741742 start: startTier,
742743 askerName: row.asked_by_username,
744+ person: row.asked_by_username,
743745 leftMicros: row.cap_micros != null ? row.cap_micros - row.charged_micros : null,
744746 limits: subagent ? subagent.routing : null,
745747 },
+119−23
2121 type AgentsOverview,
2222 type AgentMemoryScope,
2323 type NewRoutine,
24+ type PersonBudgets,
2425 type Result,
2526 type RoutineSuggestion,
2627 type SessionEvent,
3334 ok,
3435 } from "@g1t/contracts";
3536
36−import { monthKey } from "./budget.ts";
37+import { monthKey, spendSpan } from "./budget.ts";
38+import { cleanUsername, personBudgets } from "./person-budget.ts";
3739 import { type MemoryRow, type MemoryViewer, changeableBy, cleanFact, toMemory, visibleTo } from "./memory.ts";
3840 import { DEFAULT_POLICY, checkPolicy, readPolicy } from "./policy.ts";
3941 import { type RoutineRow, MAX_ROUTINES, checkRoutine, newRoutineId, nextRun, runRoutine, toRoutine } from "./routines.ts";
371373 .sort((a, b) => b.micros - a.micros);
372374 }
373375
376+/** The most channels named in a breakdown; the rest of the channels fold into one slice. */
377+const CHANNEL_SLICES = 12;
378+
379+/**
380+ * Spend by where it was asked: the costliest channels by name, if the
381+ * viewer can read them; direct messages together; channels they can't
382+ * read, and channels past the first few, folded together without names.
383+ */
384+async function channelSlices(ctx: ViewContext, rows: { key: string | null; label: string | null; micros: number; n: number }[]): Promise<SpendSlice[]> {
385+ const ranked = rows.filter((r) => r.micros > 0 || r.n > 0).sort((a, b) => b.micros - a.micros);
386+ const named = ranked.filter((r) => r.key && r.key !== "dm").slice(0, CHANNEL_SLICES);
387+ const can = await readable(ctx, named.map((r) => r.key!));
388+ const out: SpendSlice[] = [];
389+ const hidden: SpendSlice = { key: "private", label: "Channels you're not in", micros: 0, count: 0 };
390+ const rest: SpendSlice = { key: "other", label: "Other channels", micros: 0, count: 0 };
391+ for (const row of ranked) {
392+ if (row.key === "dm") out.push({ key: "dm", label: "Direct messages", micros: row.micros, count: row.n });
393+ else if (!named.includes(row)) (rest.micros += row.micros), (rest.count += row.n);
394+ else if (can.has(row.key!)) out.push({ key: row.key!, label: row.label ? `#${row.label}` : "A channel", micros: row.micros, count: row.n });
395+ else (hidden.micros += row.micros), (hidden.count += row.n);
396+ }
397+ for (const folded of [hidden, rest]) if (folded.count > 0) out.push(folded);
398+ return out.sort((a, b) => b.micros - a.micros);
399+}
400+
374401 /**
375− * Where the month went, for one agent (what it was paid for: its replies and
376− * every session it paid for, colleagues' help included) or for every agent.
402+ * Budgets per person this month (person-budget.ts): owners see everyone's,
403+ * anyone else only their own.
377404 */
378−export async function spend(ctx: ViewContext, handle: string | null): Promise<Result<AgentSpendBreakdown>> {
405+export async function personBudgetsView(ctx: ViewContext): Promise<Result<PersonBudgets>> {
379406 const now = new Date();
380− const month = monthKey(now);
381− const from = `${month}-01T00:00:00.000Z`;
407+ const policyRow = await readPolicy(ctx.db, ctx.workspaceId, monthKey(now));
408+ const only = ctx.owner ? null : ctx.viewer.kind === "workspace" ? null : ctx.viewer.username.toLowerCase();
409+ return ok(await personBudgets(ctx.db, ctx.workspaceId, policyRow.person_monthly_micros, now, only));
410+}
411+
412+/** Gives a person a budget of their own (0: none at all), or with null puts them back on the default. Owners only. */
413+export async function setPersonBudget(ctx: ViewContext, username: unknown, monthly: unknown): Promise<Result<PersonBudgets>> {
414+ if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set budgets for people.");
415+ const name = cleanUsername(username);
416+ if (!name) return fail("invalid", "That isn't a username.");
417+ if (monthly === null || monthly === undefined) {
418+ await ctx.db.prepare("DELETE FROM person_budgets WHERE workspace_id = ? AND username = ?").bind(ctx.workspaceId, name).run();
419+ } else {
420+ const value = Number(monthly);
421+ if (!Number.isFinite(value) || value < 0 || value > 1_000_000_000_000) return fail("invalid", "A budget is a positive amount, or none.");
422+ await ctx.db
423+ .prepare(
424+ `INSERT INTO person_budgets (workspace_id, username, monthly_micros, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
425+ ON CONFLICT (workspace_id, username) DO UPDATE SET monthly_micros = ?3, updated_by = ?4, updated_at = ?5`,
426+ )
427+ .bind(ctx.workspaceId, name, Math.floor(value), ctx.viewer.username, new Date().toISOString())
428+ .run();
429+ }
430+ return personBudgetsView(ctx);
431+}
432+
433+/**
434+ * Where the spend went over a span of days (this month unless asked), for
435+ * one agent (what it was paid for: its replies and every session it paid
436+ * for, colleagues' help included) or for every agent; for everyone, or only
437+ * the work one person asked for.
438+ */
439+export async function spend(ctx: ViewContext, handle: string | null, options: { period?: unknown; person?: unknown } = {}): Promise<Result<AgentSpendBreakdown>> {
440+ const now = new Date();
441+ const span = spendSpan(options.period, now);
442+ const from = `${span.from}T00:00:00.000Z`;
443+ const end = new Date(`${span.until}T00:00:00.000Z`);
444+ end.setUTCDate(end.getUTCDate() + 1);
445+ const to = end.toISOString();
446+ const person = options.person == null || options.person === "" ? null : cleanUsername(options.person);
447+ if (options.person != null && options.person !== "" && !person) return fail("invalid", "That isn't a username.");
382448 let agentId: string | null = null;
383449 if (handle) {
384450 const agent = await agentByHandle(ctx, handle);
386452 agentId = agent.id;
387453 }
388454 const db = ctx.db;
389− const rFilter = agentId ? "agent_id = ?2" : "workspace_id = ?2";
390− const sFilter = agentId ? "payer_agent_id = ?2" : "workspace_id = ?2";
391− const scope = agentId ?? ctx.workspaceId;
392− const union = `SELECT 'reply' AS kind, agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day FROM agent_replies WHERE ${rFilter} AND created_at >= ?1
393− UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NULL
394− UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, 0 AS micros, substr(created_at, 1, 10) AS day FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NOT NULL`;
455+ const who = person ? " AND asked_by_username = ?4" : "";
456+ const rFilter = `${agentId ? "agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`;
457+ const sFilter = `${agentId ? "payer_agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`;
458+ const binds: string[] = [from, agentId ?? ctx.workspaceId, to, ...(person ? [person] : [])];
459+ // Where it was asked: a channel by id; direct messages (a reply's channel has no name) together.
460+ const union = `SELECT 'reply' AS kind, agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day,
461+ CASE WHEN channel_name IS NULL THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_replies WHERE ${rFilter}
462+ UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day,
463+ CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL
464+ UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, 0 AS micros, substr(created_at, 1, 10) AS day,
465+ CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NOT NULL`;
395466 // A child's spend is already counted on its root (sessions.ts), so children add counts, not money.
396467 const group = (column: string) =>
397− db.prepare(`SELECT ${column} AS key, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY ${column}`).bind(from, scope).all<{ key: string | null; micros: number; n: number }>();
398− const [byKind, byModel, byPerson, byAgent, byDay, top, agents] = await Promise.all([
468+ db.prepare(`SELECT ${column} AS key, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY ${column}`).bind(...binds).all<{ key: string | null; micros: number; n: number }>();
469+ const [byKind, byModel, byPerson, byAgent, byDay, byChannel, top, agents] = await Promise.all([
399470 group("kind"),
400471 group("model"),
401472 group("person"),
402473 group("agent"),
403474 group("day"),
404− db.prepare(`SELECT * FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NULL ORDER BY charged_micros DESC LIMIT 8`).bind(from, scope).all<SessionRow>(),
475+ db
476+ .prepare(`SELECT channel AS key, MAX(channel_label) AS label, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY channel`)
477+ .bind(...binds)
478+ .all<{ key: string | null; label: string | null; micros: number; n: number }>(),
479+ db.prepare(`SELECT * FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL ORDER BY charged_micros DESC LIMIT 8`).bind(...binds).all<SessionRow>(),
405480 faces(ctx),
406481 ]);
482+ const channels = await channelSlices(ctx, byChannel.results);
407483 const byTeamMap = new Map<string, SpendSlice>();
408484 for (const row of byAgent.results) {
409485 const face = agents.get(row.key ?? "");
415491 }
416492 const total = byKind.results.reduce((n, r) => n + r.micros, 0);
417493 return ok({
418− period: month,
494+ period: span.period,
495+ span: span.span,
496+ from: span.from,
497+ until: span.until,
498+ person,
419499 total_micros: total,
420500 by_kind: slices(byKind.results, (k) => KIND_LABELS[k] ?? k),
421501 by_model: slices(byModel.results, (k) => k || "No model"),
425505 return face ? `${face.display_name} (@${face.handle})` : "An archived agent";
426506 }),
427507 by_team: [...byTeamMap.values()].sort((a, b) => b.micros - a.micros),
508+ by_channel: channels,
428509 top_sessions: await sessionsOut(ctx, top.results, agents),
429510 days: byDay.results.map((r) => ({ day: r.key ?? "", micros: r.micros })).sort((a, b) => a.day.localeCompare(b.day)),
430511 });
510591
511592 export async function policy(ctx: ViewContext): Promise<Result<AgentPolicy>> {
512593 const row = await readPolicy(ctx.db, ctx.workspaceId, monthKey(new Date()));
513− return ok({ monthly_micros: row.monthly_micros, default_agent_monthly_micros: row.default_agent_monthly_micros, default_session_micros: row.default_session_micros });
594+ return ok(policyOf(row));
595+}
596+
597+function policyOf(row: AgentPolicy): AgentPolicy {
598+ return {
599+ monthly_micros: row.monthly_micros,
600+ default_agent_monthly_micros: row.default_agent_monthly_micros,
601+ default_session_micros: row.default_session_micros,
602+ person_monthly_micros: row.person_monthly_micros,
603+ };
514604 }
515605
516606 export async function setPolicy(ctx: ViewContext, changes: Partial<AgentPolicy>): Promise<Result<AgentPolicy>> {
521611 const p = checked.value;
522612 await ctx.db
523613 .prepare(
524− `INSERT INTO agent_policies (workspace_id, monthly_micros, default_agent_monthly_micros, default_session_micros, updated_by, updated_at)
525− VALUES (?1, ?2, ?3, ?4, ?5, ?6)
526− ON CONFLICT (workspace_id) DO UPDATE SET monthly_micros = ?2, default_agent_monthly_micros = ?3, default_session_micros = ?4, updated_by = ?5, updated_at = ?6`,
614+ `INSERT INTO agent_policies (workspace_id, monthly_micros, default_agent_monthly_micros, default_session_micros, person_monthly_micros, updated_by, updated_at)
615+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
616+ ON CONFLICT (workspace_id) DO UPDATE SET monthly_micros = ?2, default_agent_monthly_micros = ?3, default_session_micros = ?4, person_monthly_micros = ?5, updated_by = ?6, updated_at = ?7`,
527617 )
528− .bind(ctx.workspaceId, p.monthly_micros, p.default_agent_monthly_micros, p.default_session_micros, ctx.viewer.username, new Date().toISOString())
618+ .bind(ctx.workspaceId, p.monthly_micros, p.default_agent_monthly_micros, p.default_session_micros, p.person_monthly_micros, ctx.viewer.username, new Date().toISOString())
529619 .run();
530620 return ok(p);
531621 }
554644 for (const s of live.results) liveByAgent[s.agent_id] = (liveByAgent[s.agent_id] ?? 0) + 1;
555645 const level = policyRow.monthly_micros ? [100, 90, 75].find((l) => (policyRow.spent * 100) / policyRow.monthly_micros! >= l) ?? null : null;
556646 return ok({
557− policy: { monthly_micros: policyRow.monthly_micros, default_agent_monthly_micros: policyRow.default_agent_monthly_micros, default_session_micros: policyRow.default_session_micros },
647+ policy: policyOf(policyRow),
558648 spent_month_micros: policyRow.spent,
559649 alert: level,
560650 agents,
566656 const face = agentFaces.get(r.agent_id);
567657 return { ...toRoutine(r), agent_handle: face?.handle ?? "agent", agent_name: face?.display_name ?? "An agent" };
568658 }),
569− spend: breakdown.ok ? breakdown.value : { period: monthKey(now), total_micros: 0, by_kind: [], by_model: [], by_person: [], by_agent: [], by_team: [], top_sessions: [], days: [] },
659+ spend: breakdown.ok ? breakdown.value : emptySpend(now),
570660 can_manage: ctx.owner,
571661 });
572662 }
663+
664+/** A breakdown with nothing in it, for this month. */
665+function emptySpend(now: Date): AgentSpendBreakdown {
666+ const span = spendSpan("month", now);
667+ return { ...span, person: null, total_micros: 0, by_kind: [], by_model: [], by_person: [], by_agent: [], by_team: [], by_channel: [], top_sessions: [], days: [] };
668+}