Skip to content
271 linesCodeBlameRaw
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, UsageReport, UsageShare } 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
14/**
15 * What agents cost over the report's range, at price: the agent product
16 * (model tokens, the agent rate and agents' sandbox time), every agent's
17 * replies, sessions and runs on repositories together. The top bar's and
18 * Spend's "Agents" figure, from the same ledger as "Spent", so it is part
19 * of it: Spent = Agents + the other products.
20 */
21export function agentMicros(report: Pick<UsageReport, "products">): number {
22 return report.products.find((p) => p.key === "agent")?.micros ?? 0;
23}
24
25/**
26 * The agent product by the agent that did the work and by who asked, as
27 * Spend's slices, from the ledger's own attribution. Null when billing did
28 * not attribute the range (a report from before attribution), so the
29 * caller can fall back to the agents service's own count.
30 */
31export function attributionSlices(report: Pick<UsageReport, "byAgent" | "byPerson"> | null): { agent: SpendSlice[]; person: SpendSlice[] } | null {
32 if (!report?.byAgent || !report.byPerson) return null;
33 const slice = (s: UsageShare): SpendSlice => ({ key: s.key, label: s.label, micros: s.micros, count: s.count });
34 return { agent: report.byAgent.map(slice), person: report.byPerson.map(slice) };
35}
36
37/** Where an agent's slice opens: its own Spend tab, for a slice that names one. */
38export function agentSpendHref(slug: string, slice: Pick<SpendSlice, "key">): string | null {
39 return slice.key ? `/${slug}/-/agents/${slice.key}/spend` : null;
40}
41
42export const SPEND_PERIODS: { key: SpendPeriod; label: string; short: string }[] = [
43 { key: "month", label: "This month", short: "This month" },
44 { key: "last_month", label: "Last month", short: "Last month" },
45 { key: "30d", label: "Last 30 days", short: "30 days" },
46 { key: "7d", label: "Last 7 days", short: "7 days" },
47];
48
49export function readPeriod(raw: string | null | undefined): SpendPeriod {
50 return SPEND_PERIODS.some((p) => p.key === raw) ? (raw as SpendPeriod) : "month";
51}
52
53export function periodLabel(period: SpendPeriod): string {
54 return SPEND_PERIODS.find((p) => p.key === period)?.label ?? "This month";
55}
56
57/** Whose view a request asks for: the workspace's only for someone who may see it. */
58export function readScope(raw: string | null | undefined, mayWorkspace: boolean): SpendScope {
59 if (!mayWorkspace) return "me";
60 return raw === "me" ? "me" : "workspace";
61}
62
63const iso = (at: Date) => at.toISOString().slice(0, 10);
64
65/**
66 * This month, in UTC: its first day to today, both included. The one
67 * range "this month" means on every page: the top bar's pill, Spend's
68 * "This month", Home's "this month so far" and Usage's current cycle all
69 * read it, so the same month shows the same number everywhere. Billing
70 * adds the month's usage not yet closed (`pending_micros`) to any range
71 * that reaches into the current month, so `until` need not be month end.
72 */
73export function monthSpan(now: Date): { from: string; until: string } {
74 return { from: iso(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))), until: iso(now) };
75}
76
77/**
78 * What the workspace used over the report's range, at price: the figure
79 * the pill, Spend's "Spent", Home's "this month so far" and Usage's
80 * "Usage at price" all show. Billing already measures at cost while g1t
81 * is free, so the report's price is the one figure in every mode.
82 */
83export function spentMicros(report: Pick<UsageReport, "totals">): number {
84 return report.totals.priceMicros;
85}
86
87/**
88 * The days a period covers in UTC, both ends included, as the agents
89 * service counts them (services/agents/src/budget.ts `spendSpan`).
90 */
91export function spanFor(period: SpendPeriod, now: Date): { from: string; until: string } {
92 const y = now.getUTCFullYear();
93 const m = now.getUTCMonth();
94 const d = now.getUTCDate();
95 switch (period) {
96 case "last_month":
97 return { from: iso(new Date(Date.UTC(y, m - 1, 1))), until: iso(new Date(Date.UTC(y, m, 0))) };
98 case "7d":
99 return { from: iso(new Date(Date.UTC(y, m, d - 6))), until: iso(now) };
100 case "30d":
101 return { from: iso(new Date(Date.UTC(y, m, d - 29))), until: iso(now) };
102 default:
103 return monthSpan(now);
104 }
105}
106
107/** Every day from `from` to `until`, with what was spent on it; days with none are 0. */
108export function daySeries(from: string, until: string, spent: { day: string; micros: number }[]): { day: string; micros: number }[] {
109 const by = new Map<string, number>();
110 for (const s of spent) by.set(s.day, (by.get(s.day) ?? 0) + s.micros);
111 const out: { day: string; micros: number }[] = [];
112 const at = new Date(`${from}T00:00:00Z`);
113 const end = new Date(`${until}T00:00:00Z`);
114 if (Number.isNaN(at.getTime()) || Number.isNaN(end.getTime())) return out;
115 for (let n = 0; at <= end && n < 400; n++) {
116 const key = iso(at);
117 out.push({ day: key, micros: by.get(key) ?? 0 });
118 at.setUTCDate(at.getUTCDate() + 1);
119 }
120 return out;
121}
122
123/** Usage's days (one row per product a day) as one total a day. */
124export function usageByDay(days: UsageDay[]): { day: string; micros: number }[] {
125 return days.map((d) => ({ day: d.day, micros: d.micros }));
126}
127
128/** What the price book says about pricing, for the page's pricing card. */
129export type Pricing = {
130 /** What models are marked up, in percent: 0 is the provider's price. */
131 modelMarkupPercent: number;
132 /** What g1t's own metered work is marked up: one figure, or the range across meters. */
133 markup: { min: number; max: number } | null;
134 /** g1t's agent rate per million tokens, on g1t's models and on your own key, in force now. Null when the book has none. */
135 agentRateMicros: number | null;
136 agentRateOwnMicros: number | null;
137 /** The agent rate still to come, when it is $0 now and a dated version waits: its price and the day it starts. */
138 agentRateComing: { micros: number; from: string } | null;
139};
140
141/**
142 * Meters that are not something g1t runs at a cost: models (at the
143 * provider's price), AI Gateway, card fees (Stripe's), flat activations,
144 * and per-token rates with their weights.
145 */
146const NOT_RUN = (meter: string) =>
147 meter === "agent_models" || meter === "gateway_models" || meter.startsWith("agent_token") || meter.startsWith("card_fee") || meter === "security_activation";
148
149export function pricingOf(book: PriceBook): Pricing {
150 const run = book.prices.filter((p) => !NOT_RUN(p.meter) && p.costMicros > 0);
151 const markups = run.map((p) => p.markupPercent);
152 const rate = (meter: string) => {
153 const price = book.prices.find((p) => p.meter === meter);
154 return price ? price.priceMicros : null;
155 };
156 const coming = book.changes.find((c) => c.meter === "agent_tokens" && c.effectiveAt);
157 return {
158 modelMarkupPercent: book.modelMarginPercent,
159 markup: markups.length ? { min: Math.min(...markups), max: Math.max(...markups) } : null,
160 agentRateMicros: rate("agent_tokens"),
161 agentRateOwnMicros: rate("agent_tokens_own"),
162 agentRateComing: coming ? { micros: coming.newCostMicros, from: coming.effectiveAt!.slice(0, 10) } : null,
163 };
164}
165
166/** "$0.25 per million tokens", or "$0.25 per million tokens from 2026-10-22" while it waits for its date. */
167export function agentRateLabel(pricing: Pick<Pricing, "agentRateMicros" | "agentRateComing">, money: (micros: number) => string): string {
168 if (pricing.agentRateMicros && pricing.agentRateMicros > 0) return `${money(pricing.agentRateMicros)} per million tokens`;
169 if (pricing.agentRateComing) return `${money(pricing.agentRateComing.micros)} per million tokens from ${pricing.agentRateComing.from}`;
170 return "Per million tokens";
171}
172
173/** "20%", or "15–20%" when meters differ. */
174export function markupLabel(markup: { min: number; max: number }): string {
175 return markup.min === markup.max ? `${markup.min}%` : `${markup.min}–${markup.max}%`;
176}
177
178/** One line of a task's receipt: one session of its tree. */
179export type ReceiptLine = {
180 session: AgentSession;
181 depth: number;
182 /** What this session's own steps were charged (a root's charge includes its tree's). */
183 ownMicros: number;
184};
185
186export type Receipt = {
187 root: AgentSession;
188 lines: ReceiptLine[];
189 /** The whole tree, as the root's charge counts it. */
190 chargedMicros: number;
191 /** The model answers at the provider's price, every session's together. */
192 providerMicros: number;
193 inputTokens: number;
194 outputTokens: number;
195 steps: number;
196 toolCalls: number;
197};
198
199/**
200 * A task's receipt from its session tree (root first, as the agents
201 * service gives it): each session's own charge, children under their
202 * parents, and the totals. A child's charge is added to the root as it is
203 * spent, so the root's own share is its charge less its children's.
204 */
205export function receiptOf(tree: AgentSession[], rootId: string): Receipt | null {
206 const root = tree.find((s) => s.id === rootId) ?? tree.find((s) => s.parent_id == null);
207 if (!root) return null;
208 const children = new Map<string, AgentSession[]>();
209 for (const s of tree) {
210 if (s.id === root.id || !s.parent_id) continue;
211 children.set(s.parent_id, [...(children.get(s.parent_id) ?? []), s]);
212 }
213 const lines: ReceiptLine[] = [];
214 const seen = new Set<string>();
215 const walk = (s: AgentSession, depth: number) => {
216 if (seen.has(s.id)) return;
217 seen.add(s.id);
218 lines.push({ session: s, depth, ownMicros: s.charged_micros });
219 for (const kid of [...(children.get(s.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at))) walk(kid, depth + 1);
220 };
221 walk(root, 0);
222 const others = lines.slice(1).reduce((n, l) => n + l.ownMicros, 0);
223 lines[0]!.ownMicros = Math.max(0, root.charged_micros - others);
224 const sum = (pick: (s: AgentSession) => number) => lines.reduce((n, l) => n + (pick(l.session) || 0), 0);
225 return {
226 root,
227 lines,
228 chargedMicros: root.charged_micros,
229 providerMicros: sum((s) => s.cost_micros ?? 0),
230 inputTokens: sum((s) => s.input_tokens),
231 outputTokens: sum((s) => s.output_tokens),
232 steps: sum((s) => s.steps),
233 toolCalls: sum((s) => s.tool_calls),
234 };
235}
236
237/** A count of tokens for a receipt: "412K", "1.2M", "830". */
238export function tokenCount(n: number): string {
239 if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`;
240 if (n >= 10_000) return `${Math.round(n / 1000).toLocaleString("en-US")}K`;
241 if (n >= 1000) return `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`;
242 return n.toLocaleString("en-US");
243}
244
245/** How far through a budget: 0 to 1 (more when over), null with none. */
246export function shareOfBudget(spent: number, budget: number | null | undefined): number | null {
247 return budget != null && budget > 0 ? Math.max(0, spent) / budget : null;
248}
249
250/** "62%" of a budget, for a pill or a row; empty with none. */
251export function percentLabel(spent: number, budget: number | null | undefined): string {
252 const share = shareOfBudget(spent, budget);
253 return share == null ? "" : `${Math.round(share * 100)}%`;
254}
255
256/** The slices a person's own view leaves out: the "who asked" one is only ever them. */
257export function withoutSelf(slices: SpendSlice[], username: string): SpendSlice[] {
258 return slices.filter((s) => s.key !== username);
259}
260
261/** What a level of budget does when it is reached, as the page says it. */
262export const AT_LIMIT = {
263 workspace: (pauses: boolean) =>
264 pauses
265 ? "New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. Work already running finishes."
266 : "Owners are alerted, and work goes on; g1t's own ceiling still applies.",
267 agents: "No agent takes new work until the 1st, or until an owner raises it.",
268 person: "Agents take no new work for that person until the 1st; they say so where they were asked.",
269 agent: "That agent takes no new work until the 1st, or the next day for a daily cap.",
270 session: "The session stops at Needs approval, and an owner decides whether it goes on.",
271} as const;