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
290 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 }
195 case "bytes":
196 return bytes(amount);
197 case "operations":
198 return `${compact(amount)} ${amount === 1 ? "operation" : "operations"}`;
199 case "requests":
200 return `${compact(amount)} ${amount === 1 ? "request" : "requests"}`;
201 case "micros":
202 return money(amount);
203 default:
204 return `${compact(amount)} ${amount === 1 ? "entry" : "entries"}`;
205 }
206}
207
208/** Storage in powers of ten, as it is priced: `504 MB`, `1 TB`. */
209export function bytes(n: number): string {
210 const units: [number, string][] = [
211 [1e12, "TB"],
212 [1e9, "GB"],
213 [1e6, "MB"],
214 [1e3, "KB"],
215 ];
216 for (const [size, name] of units) {
217 if (n >= size) return `${(n / size).toLocaleString("en-US", { maximumFractionDigits: n / size >= 100 ? 0 : 1 })} ${name}`;
218 }
219 return `${Math.round(n)} B`;
220}
221
222/** What paid for usage, in order, each line with anything to show: the receipt under the totals. */
223export function receipt(totals: UsageTotals, discountPercent: number | null | undefined): { label: string; micros: number; minus: boolean }[] {
224 const lines: { label: string; micros: number; minus: boolean }[] = [{ label: "Usage at price", micros: totals.priceMicros, minus: false }];
225 if (totals.discountMicros > 0) lines.push({ label: `Discount${discountPercent ? ` (${discountPercent}%)` : ""}`, micros: totals.discountMicros, minus: true });
226 if (totals.includedMicros > 0) lines.push({ label: "Included usage and pools", micros: totals.includedMicros, minus: true });
227 if (totals.creditsMicros > 0) lines.push({ label: "Credits applied", micros: totals.creditsMicros, minus: true });
228 lines.push({ label: "Charged", micros: totals.chargedMicros, minus: false });
229 return lines;
230}
231
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.232/**
233 * The note under the receipt: what is metered this month and not yet
234 * closed, with the fraction of a cent it carries (it explains a gap of
235 * one), and what of it the close will charge on the account's terms. An
236 * older billing says only that it is charged at the close.
237 */
238export function pendingSentence(totals: Pick<UsageTotals, "pendingMicros" | "pendingChargedMicros">): string {
239 const pending = money(totals.pendingMicros, { precise: true });
240 const charged = totals.pendingChargedMicros;
241 if (charged == null) return `${pending} of it is metered this month and not yet closed; it is charged when the month closes.`;
242 const part = charged <= 0 ? "nothing" : charged >= totals.pendingMicros ? "all of it" : `${money(charged, { precise: true })} of it`;
243 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.`;
244}
245
Usage, Billing settings and prepaid AI credit; fixes from the UX audit246/** Rows of the breakdown when grouped by project: each project's usage across every meter. */
247export function byProject(report: Pick<UsageReport, "products">): { project: string; micros: number; meters: { label: string; micros: number }[] }[] {
248 const rows = new Map<string, { project: string; micros: number; meters: { label: string; micros: number }[] }>();
249 for (const product of report.products) {
250 for (const meter of product.meters) {
251 for (const part of meter.byProject) {
252 if (part.micros === 0) continue;
253 const row = rows.get(part.project) ?? { project: part.project, micros: 0, meters: [] };
254 row.micros += part.micros;
255 row.meters.push({ label: meter.label, micros: part.micros });
256 rows.set(part.project, row);
257 }
258 }
259 }
260 return [...rows.values()].sort((a, b) => b.micros - a.micros);
261}
262
263/** The report as CSV: one row per meter, day and amount at price. */
264export function usageCsv(report: Pick<UsageReport, "from" | "until" | "products">): string {
265 const days = daysIn(report.from, report.until);
266 const quote = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
267 const lines = ["day,product,meter,usd"];
268 for (const product of report.products) {
269 for (const meter of product.meters) {
270 meter.daily.forEach((micros, i) => {
271 if (micros !== 0) lines.push([days[i] ?? "", quote(product.label), quote(meter.label), (micros / MICROS_PER_DOLLAR).toFixed(6)].join(","));
272 });
273 if ((meter.pendingMicros ?? 0) !== 0) {
274 lines.push(["pending", quote(product.label), quote(meter.label), ((meter.pendingMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6)].join(","));
275 }
276 }
277 }
278 return `${lines.join("\n")}\n`;
279}
280
281/** The meters worth a row: anything used, and those with an allowance. */
282export function shownLines(meters: MeterLine[]): MeterLine[] {
283 return meters.filter((m) => m.micros !== 0 || m.quantity !== 0 || m.allowance);
284}
285
286/** Who sees test-mode hints: g1t's own people (members of Flagon's workspace). */
287export const STAFF_WORKSPACE = "flagon-io";
288export function isStaff(viewer: { workspaces?: { slug: string }[] } | null | undefined): boolean {
289 return !!viewer?.workspaces?.some((w) => w.slug === STAFF_WORKSPACE);
290}