Skip to content
204 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.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 */
9import 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. */
12export type SpendScope = "me" | "workspace";
13
14export 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
21export function readPeriod(raw: string | null | undefined): SpendPeriod {
22 return SPEND_PERIODS.some((p) => p.key === raw) ? (raw as SpendPeriod) : "month";
23}
24
25export 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. */
30export function readScope(raw: string | null | undefined, mayWorkspace: boolean): SpendScope {
31 if (!mayWorkspace) return "me";
32 return raw === "me" ? "me" : "workspace";
33}
34
35const 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 */
41export 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. */
58export 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. */
74export 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. */
79export 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
89const AGENT_METERS = new Set(["agent_models", "agent_tokens", "agent_tokens_own"]);
90
91export 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. */
107export 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. */
112export 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
119export 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 */
138export 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". */
171export 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. */
179export 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. */
184export 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. */
190export 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. */
195export 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;