Skip to content
249 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 * The days a period covers in UTC, both ends included, as the agents
67 * service counts them (services/agents/src/budget.ts `spendSpan`).
68 */
69export function spanFor(period: SpendPeriod, now: Date): { from: string; until: string } {
70 const y = now.getUTCFullYear();
71 const m = now.getUTCMonth();
72 const d = now.getUTCDate();
73 switch (period) {
74 case "last_month":
75 return { from: iso(new Date(Date.UTC(y, m - 1, 1))), until: iso(new Date(Date.UTC(y, m, 0))) };
76 case "7d":
77 return { from: iso(new Date(Date.UTC(y, m, d - 6))), until: iso(now) };
78 case "30d":
79 return { from: iso(new Date(Date.UTC(y, m, d - 29))), until: iso(now) };
80 default:
81 return { from: iso(new Date(Date.UTC(y, m, 1))), until: iso(now) };
82 }
83}
84
85/** Every day from `from` to `until`, with what was spent on it; days with none are 0. */
86export function daySeries(from: string, until: string, spent: { day: string; micros: number }[]): { day: string; micros: number }[] {
87 const by = new Map<string, number>();
88 for (const s of spent) by.set(s.day, (by.get(s.day) ?? 0) + s.micros);
89 const out: { day: string; micros: number }[] = [];
90 const at = new Date(`${from}T00:00:00Z`);
91 const end = new Date(`${until}T00:00:00Z`);
92 if (Number.isNaN(at.getTime()) || Number.isNaN(end.getTime())) return out;
93 for (let n = 0; at <= end && n < 400; n++) {
94 const key = iso(at);
95 out.push({ day: key, micros: by.get(key) ?? 0 });
96 at.setUTCDate(at.getUTCDate() + 1);
97 }
98 return out;
99}
100
101/** Usage's days (one row per product a day) as one total a day. */
102export function usageByDay(days: UsageDay[]): { day: string; micros: number }[] {
103 return days.map((d) => ({ day: d.day, micros: d.micros }));
104}
105
106/** What the price book says about pricing, for the page's pricing card. */
107export type Pricing = {
108 /** What models are marked up, in percent: 0 is the provider's price. */
109 modelMarkupPercent: number;
110 /** What g1t's own metered work is marked up: one figure, or the range across meters. */
111 markup: { min: number; max: number } | null;
112 /** 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. */
113 agentRateMicros: number | null;
114 agentRateOwnMicros: number | null;
115 /** The agent rate still to come, when it is $0 now and a dated version waits: its price and the day it starts. */
116 agentRateComing: { micros: number; from: string } | null;
117};
118
119/**
120 * Meters that are not something g1t runs at a cost: models (at the
121 * provider's price), AI Gateway, card fees (Stripe's), flat activations,
122 * and per-token rates with their weights.
123 */
124const NOT_RUN = (meter: string) =>
125 meter === "agent_models" || meter === "gateway_models" || meter.startsWith("agent_token") || meter.startsWith("card_fee") || meter === "security_activation";
126
127export function pricingOf(book: PriceBook): Pricing {
128 const run = book.prices.filter((p) => !NOT_RUN(p.meter) && p.costMicros > 0);
129 const markups = run.map((p) => p.markupPercent);
130 const rate = (meter: string) => {
131 const price = book.prices.find((p) => p.meter === meter);
132 return price ? price.priceMicros : null;
133 };
134 const coming = book.changes.find((c) => c.meter === "agent_tokens" && c.effectiveAt);
135 return {
136 modelMarkupPercent: book.modelMarginPercent,
137 markup: markups.length ? { min: Math.min(...markups), max: Math.max(...markups) } : null,
138 agentRateMicros: rate("agent_tokens"),
139 agentRateOwnMicros: rate("agent_tokens_own"),
140 agentRateComing: coming ? { micros: coming.newCostMicros, from: coming.effectiveAt!.slice(0, 10) } : null,
141 };
142}
143
144/** "$0.25 per million tokens", or "$0.25 per million tokens from 2026-10-22" while it waits for its date. */
145export function agentRateLabel(pricing: Pick<Pricing, "agentRateMicros" | "agentRateComing">, money: (micros: number) => string): string {
146 if (pricing.agentRateMicros && pricing.agentRateMicros > 0) return `${money(pricing.agentRateMicros)} per million tokens`;
147 if (pricing.agentRateComing) return `${money(pricing.agentRateComing.micros)} per million tokens from ${pricing.agentRateComing.from}`;
148 return "Per million tokens";
149}
150
151/** "20%", or "15–20%" when meters differ. */
152export function markupLabel(markup: { min: number; max: number }): string {
153 return markup.min === markup.max ? `${markup.min}%` : `${markup.min}–${markup.max}%`;
154}
155
156/** One line of a task's receipt: one session of its tree. */
157export type ReceiptLine = {
158 session: AgentSession;
159 depth: number;
160 /** What this session's own steps were charged (a root's charge includes its tree's). */
161 ownMicros: number;
162};
163
164export type Receipt = {
165 root: AgentSession;
166 lines: ReceiptLine[];
167 /** The whole tree, as the root's charge counts it. */
168 chargedMicros: number;
169 /** The model answers at the provider's price, every session's together. */
170 providerMicros: number;
171 inputTokens: number;
172 outputTokens: number;
173 steps: number;
174 toolCalls: number;
175};
176
177/**
178 * A task's receipt from its session tree (root first, as the agents
179 * service gives it): each session's own charge, children under their
180 * parents, and the totals. A child's charge is added to the root as it is
181 * spent, so the root's own share is its charge less its children's.
182 */
183export function receiptOf(tree: AgentSession[], rootId: string): Receipt | null {
184 const root = tree.find((s) => s.id === rootId) ?? tree.find((s) => s.parent_id == null);
185 if (!root) return null;
186 const children = new Map<string, AgentSession[]>();
187 for (const s of tree) {
188 if (s.id === root.id || !s.parent_id) continue;
189 children.set(s.parent_id, [...(children.get(s.parent_id) ?? []), s]);
190 }
191 const lines: ReceiptLine[] = [];
192 const seen = new Set<string>();
193 const walk = (s: AgentSession, depth: number) => {
194 if (seen.has(s.id)) return;
195 seen.add(s.id);
196 lines.push({ session: s, depth, ownMicros: s.charged_micros });
197 for (const kid of [...(children.get(s.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at))) walk(kid, depth + 1);
198 };
199 walk(root, 0);
200 const others = lines.slice(1).reduce((n, l) => n + l.ownMicros, 0);
201 lines[0]!.ownMicros = Math.max(0, root.charged_micros - others);
202 const sum = (pick: (s: AgentSession) => number) => lines.reduce((n, l) => n + (pick(l.session) || 0), 0);
203 return {
204 root,
205 lines,
206 chargedMicros: root.charged_micros,
207 providerMicros: sum((s) => s.cost_micros ?? 0),
208 inputTokens: sum((s) => s.input_tokens),
209 outputTokens: sum((s) => s.output_tokens),
210 steps: sum((s) => s.steps),
211 toolCalls: sum((s) => s.tool_calls),
212 };
213}
214
215/** A count of tokens for a receipt: "412K", "1.2M", "830". */
216export function tokenCount(n: number): string {
217 if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`;
218 if (n >= 10_000) return `${Math.round(n / 1000).toLocaleString("en-US")}K`;
219 if (n >= 1000) return `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`;
220 return n.toLocaleString("en-US");
221}
222
223/** How far through a budget: 0 to 1 (more when over), null with none. */
224export function shareOfBudget(spent: number, budget: number | null | undefined): number | null {
225 return budget != null && budget > 0 ? Math.max(0, spent) / budget : null;
226}
227
228/** "62%" of a budget, for a pill or a row; empty with none. */
229export function percentLabel(spent: number, budget: number | null | undefined): string {
230 const share = shareOfBudget(spent, budget);
231 return share == null ? "" : `${Math.round(share * 100)}%`;
232}
233
234/** The slices a person's own view leaves out: the "who asked" one is only ever them. */
235export function withoutSelf(slices: SpendSlice[], username: string): SpendSlice[] {
236 return slices.filter((s) => s.key !== username);
237}
238
239/** What a level of budget does when it is reached, as the page says it. */
240export const AT_LIMIT = {
241 workspace: (pauses: boolean) =>
242 pauses
243 ? "New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. Work already running finishes."
244 : "Owners are alerted, and work goes on; g1t's own ceiling still applies.",
245 agents: "No agent takes new work until the 1st, or until an owner raises it.",
246 person: "Agents take no new work for that person until the 1st; they say so where they were asked.",
247 agent: "That agent takes no new work until the 1st, or the next day for a daily cap.",
248 session: "The session stops at Needs approval, and an owner decides whether it goes on.",
249} as const;