Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
292 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.

Usage, Billing settings and prepaid AI credit; fixes from the UX audit1/**
2 * The Usage page's arithmetic: which days a period covers, how a report's
3 * days become the chart's columns, clean axis ticks, quantities in their
4 * units, and the CSV export. Pure, so it can be tested.
5 *
6 * Every figure is usage at price (`UsageReport.totals.priceMicros`), the
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.7 * one number Home, Usage and Billing all show:
Usage, Billing settings and prepaid AI credit; fixes from the UX audit8 * what was charged, plus what included usage, credit or a discount paid.
9 */
10
11import type { MeterLine, UsageDay, UsageReport, UsageTotals } from "@g1t/contracts";
12
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.13import { MICROS_PER_DOLLAR, money } from "./money.ts";
14
Usage, Billing settings and prepaid AI credit; fixes from the UX audit15const DAY_MS = 86_400_000;
16
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.17/** This month so far, in UTC days: the range Home and Usage read for "this month". */
18export function monthSpan(now: Date): { from: string; until: string } {
19 return { from: day(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))), until: day(now) };
20}
21
Usage, Billing settings and prepaid AI credit; fixes from the UX audit22/** The product families in order, with their names and colors (the chart's categorical slots, validated against the dark surface). */
23export const PRODUCT_STYLE: { key: string; label: string; color: string }[] = [
24 { key: "sandboxes", label: "Sandboxes", color: "#d95926" },
25 { key: "deployments", label: "Deployments", color: "#c98500" },
26 { key: "git_storage", label: "Git & storage", color: "#d55181" },
27 { key: "packages", label: "Packages", color: "#008300" },
28 { key: "security", label: "Security & quality", color: "#9085e9" },
29 { key: "search", label: "Search", color: "#e66767" },
30];
31
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.32/** A product's name and color; one no longer offered (older usage) reads in grey under its own key. */
Usage, Billing settings and prepaid AI credit; fixes from the UX audit33export function productStyle(key: string): { key: string; label: string; color: string } {
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.34 return PRODUCT_STYLE.find((p) => p.key === key) ?? { key, label: key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "), color: "#86868e" };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit35}
36
37/** The periods the page offers. Billing cycles are calendar months (UTC). */
38export const PERIODS = {
39 cycle: "Current billing cycle",
40 last_cycle: "Last billing cycle",
41 "7d": "Last 7 days",
42 "30d": "Last 30 days",
43 "90d": "Last 90 days",
44 custom: "Custom range",
45} as const;
46export type Period = keyof typeof PERIODS;
47
48export type Grain = "day" | "week" | "month";
49export type GroupBy = "product" | "project" | "day";
50
51function day(at: Date): string {
52 return at.toISOString().slice(0, 10);
53}
54
55function isDay(text: string | null | undefined): text is string {
56 return !!text && /^\d{4}-\d{2}-\d{2}$/.test(text) && !Number.isNaN(Date.parse(`${text}T00:00:00Z`)) && day(new Date(`${text}T00:00:00Z`)) === text;
57}
58
59/** The days a period covers, both included, as `YYYY-MM-DD` (UTC), and the period actually used. */
60export function resolveRange(
61 asked: string | null | undefined,
62 custom: { from?: string | null; until?: string | null },
63 now = new Date(),
64): { period: Period; from: string; until: string } {
65 const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
66 const period: Period = asked && asked in PERIODS ? (asked as Period) : "cycle";
67 if (period === "custom" && isDay(custom.from) && isDay(custom.until)) {
68 const [from, until] = custom.from <= custom.until ? [custom.from, custom.until] : [custom.until, custom.from];
69 // At most 400 days, as billing reads them.
70 const earliest = day(new Date(Date.parse(`${until}T00:00:00Z`) - 399 * DAY_MS));
71 return { period, from: from < earliest ? earliest : from, until };
72 }
73 if (period === "last_cycle") {
74 const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1));
75 const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0));
76 return { period, from: day(start), until: day(end) };
77 }
78 if (period === "7d" || period === "30d" || period === "90d") {
79 const days = Number(period.slice(0, -1));
80 return { period, from: day(new Date(today.getTime() - (days - 1) * DAY_MS)), until: day(today) };
81 }
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.82 // This month is the one range Home reads too (`monthSpan`).
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.83 return { period: "cycle", ...monthSpan(today) };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit84}
85
86/** `Oct 1 – Oct 8, 2026`. */
87export function rangeLabel(from: string, until: string): string {
88 const f = new Date(`${from}T00:00:00Z`);
89 const u = new Date(`${until}T00:00:00Z`);
90 const short = (d: Date, year: boolean) =>
91 d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC", ...(year ? { year: "numeric" } : {}) });
92 return from === until ? short(u, true) : `${short(f, f.getUTCFullYear() !== u.getUTCFullYear())} – ${short(u, true)}`;
93}
94
95/** Every day from `from` to `until`, both included. */
96export function daysIn(from: string, until: string): string[] {
97 const out: string[] = [];
98 for (let t = Date.parse(`${from}T00:00:00Z`); t <= Date.parse(`${until}T00:00:00Z`); t += DAY_MS) out.push(day(new Date(t)));
99 return out;
100}
101
102/** One column of the chart: a day, a week (from its Monday) or a month, with each product's part. */
103export type Column = { key: string; label: string; from: string; until: string; parts: Record<string, number>; total: number };
104
105function columnKey(d: string, grain: Grain): string {
106 if (grain === "month") return d.slice(0, 7);
107 if (grain === "week") {
108 const t = new Date(`${d}T00:00:00Z`);
109 const monday = new Date(t.getTime() - ((t.getUTCDay() + 6) % 7) * DAY_MS);
110 return day(monday);
111 }
112 return d;
113}
114
115function columnLabel(key: string, grain: Grain): string {
116 if (grain === "month") return new Date(`${key}-01T00:00:00Z`).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
117 return new Date(`${key}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
118}
119
120/**
121 * The chart's columns: every day (or week, or month) of the range, zeros
122 * included, each product's usage at price. Cumulative adds each column to
123 * the ones before, product by product.
124 */
125export function columns(days: UsageDay[], from: string, until: string, grain: Grain = "day", cumulative = false): Column[] {
126 const out: Column[] = [];
127 const at = new Map<string, Column>();
128 for (const d of daysIn(from, until)) {
129 const key = columnKey(d, grain);
130 let column = at.get(key);
131 if (!column) {
132 column = { key, label: columnLabel(key, grain), from: d, until: d, parts: {}, total: 0 };
133 at.set(key, column);
134 out.push(column);
135 }
136 column.until = d;
137 }
138 for (const d of days) {
139 const column = at.get(columnKey(d.day, grain));
140 if (!column) continue;
141 column.parts[d.product] = (column.parts[d.product] ?? 0) + d.micros;
142 column.total += d.micros;
143 }
144 if (cumulative) {
145 const running: Record<string, number> = {};
146 for (const column of out) {
147 for (const [product, micros] of Object.entries(column.parts)) running[product] = (running[product] ?? 0) + micros;
148 column.parts = { ...running };
149 column.total = Object.values(running).reduce((a, b) => a + b, 0);
150 }
151 }
152 return out;
153}
154
155/** The finest grain that keeps the chart readable: days up to 45 of them, weeks up to 200, months past that. */
156export function defaultGrain(from: string, until: string): Grain {
157 const days = daysIn(from, until).length;
158 return days <= 45 ? "day" : days <= 200 ? "week" : "month";
159}
160
161/**
162 * Clean ticks for a money axis from 0 to at least `max` micros: 1, 2 or 5
163 * times a power of ten, every label different. With nothing used, one tick
164 * at $0.
165 */
166export function ticks(max: number, count = 4): number[] {
167 if (!(max > 0)) return [0];
168 const raw = max / count;
169 const power = 10 ** Math.floor(Math.log10(raw));
170 const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? 10 * power;
171 const out: number[] = [];
172 for (let v = 0; v < max + step / 2 && out.length <= count + 1; v += step) out.push(Math.round(v));
173 if (out[out.length - 1]! < max) out.push(Math.round(out[out.length - 1]! + step));
174 return out;
175}
176
177function compact(n: number): string {
178 if (n >= 1e9) return `${(n / 1e9).toLocaleString("en-US", { maximumFractionDigits: 1 })}B`;
179 if (n >= 1e6) return `${(n / 1e6).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`;
180 if (n >= 1e4) return `${(n / 1e3).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`;
181 return Math.round(n).toLocaleString("en-US");
182}
183
184/** How much of a meter, in its unit: `1.2M tokens`, `3h 12m`, `504 MB`, `12 entries`. */
185export function quantity(amount: number, unit: string): string {
186 switch (unit) {
187 case "tokens":
188 return `${compact(amount)} tokens`;
189 case "seconds": {
190 const s = Math.round(amount);
191 const h = Math.floor(s / 3600);
192 const m = Math.floor((s % 3600) / 60);
193 return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
194 }
Merge branch 'worktree-agent-a56293a6f836cd343'195 case "minutes":
196 return `${compact(amount)} ${Math.round(amount) === 1 ? "minute" : "minutes"}`;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit197 case "bytes":
198 return bytes(amount);
199 case "operations":
200 return `${compact(amount)} ${amount === 1 ? "operation" : "operations"}`;
201 case "requests":
202 return `${compact(amount)} ${amount === 1 ? "request" : "requests"}`;
203 case "micros":
204 return money(amount);
205 default:
206 return `${compact(amount)} ${amount === 1 ? "entry" : "entries"}`;
207 }
208}
209
210/** Storage in powers of ten, as it is priced: `504 MB`, `1 TB`. */
211export function bytes(n: number): string {
212 const units: [number, string][] = [
213 [1e12, "TB"],
214 [1e9, "GB"],
215 [1e6, "MB"],
216 [1e3, "KB"],
217 ];
218 for (const [size, name] of units) {
219 if (n >= size) return `${(n / size).toLocaleString("en-US", { maximumFractionDigits: n / size >= 100 ? 0 : 1 })} ${name}`;
220 }
221 return `${Math.round(n)} B`;
222}
223
224/** What paid for usage, in order, each line with anything to show: the receipt under the totals. */
225export function receipt(totals: UsageTotals, discountPercent: number | null | undefined): { label: string; micros: number; minus: boolean }[] {
226 const lines: { label: string; micros: number; minus: boolean }[] = [{ label: "Usage at price", micros: totals.priceMicros, minus: false }];
227 if (totals.discountMicros > 0) lines.push({ label: `Discount${discountPercent ? ` (${discountPercent}%)` : ""}`, micros: totals.discountMicros, minus: true });
228 if (totals.includedMicros > 0) lines.push({ label: "Included usage and pools", micros: totals.includedMicros, minus: true });
229 if (totals.creditsMicros > 0) lines.push({ label: "Credits applied", micros: totals.creditsMicros, minus: true });
230 lines.push({ label: "Charged", micros: totals.chargedMicros, minus: false });
231 return lines;
232}
233
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.234/**
235 * The note under the receipt: what is metered this month and not yet
236 * closed, with the fraction of a cent it carries (it explains a gap of
237 * one), and what of it the close will charge on the account's terms. An
238 * older billing says only that it is charged at the close.
239 */
240export function pendingSentence(totals: Pick<UsageTotals, "pendingMicros" | "pendingChargedMicros">): string {
241 const pending = money(totals.pendingMicros, { precise: true });
242 const charged = totals.pendingChargedMicros;
243 if (charged == null) return `${pending} of it is metered this month and not yet closed; it is charged when the month closes.`;
244 const part = charged <= 0 ? "nothing" : charged >= totals.pendingMicros ? "all of it" : `${money(charged, { precise: true })} of it`;
245 return `${pending} of it is metered this month and not yet closed; ${part} will be charged when the month closes, after your discount and included usage.`;
246}
247
Usage, Billing settings and prepaid AI credit; fixes from the UX audit248/** Rows of the breakdown when grouped by project: each project's usage across every meter. */
249export function byProject(report: Pick<UsageReport, "products">): { project: string; micros: number; meters: { label: string; micros: number }[] }[] {
250 const rows = new Map<string, { project: string; micros: number; meters: { label: string; micros: number }[] }>();
251 for (const product of report.products) {
252 for (const meter of product.meters) {
253 for (const part of meter.byProject) {
254 if (part.micros === 0) continue;
255 const row = rows.get(part.project) ?? { project: part.project, micros: 0, meters: [] };
256 row.micros += part.micros;
257 row.meters.push({ label: meter.label, micros: part.micros });
258 rows.set(part.project, row);
259 }
260 }
261 }
262 return [...rows.values()].sort((a, b) => b.micros - a.micros);
263}
264
265/** The report as CSV: one row per meter, day and amount at price. */
266export function usageCsv(report: Pick<UsageReport, "from" | "until" | "products">): string {
267 const days = daysIn(report.from, report.until);
268 const quote = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
269 const lines = ["day,product,meter,usd"];
270 for (const product of report.products) {
271 for (const meter of product.meters) {
272 meter.daily.forEach((micros, i) => {
273 if (micros !== 0) lines.push([days[i] ?? "", quote(product.label), quote(meter.label), (micros / MICROS_PER_DOLLAR).toFixed(6)].join(","));
274 });
275 if ((meter.pendingMicros ?? 0) !== 0) {
276 lines.push(["pending", quote(product.label), quote(meter.label), ((meter.pendingMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6)].join(","));
277 }
278 }
279 }
280 return `${lines.join("\n")}\n`;
281}
282
283/** The meters worth a row: anything used, and those with an allowance. */
284export function shownLines(meters: MeterLine[]): MeterLine[] {
285 return meters.filter((m) => m.micros !== 0 || m.quantity !== 0 || m.allowance);
286}
287
288/** Who sees test-mode hints: g1t's own people (members of Flagon's workspace). */
289export const STAFF_WORKSPACE = "flagon-io";
290export function isStaff(viewer: { workspaces?: { slug: string }[] } | null | undefined): boolean {
291 return !!viewer?.workspaces?.some((w) => w.slug === STAFF_WORKSPACE);
292}

This file's history is long; its oldest lines are credited to the oldest commit read.