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