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
298 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
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.37/** Products no longer offered: older usage may still name them, but the filters do not. */
38const RETIRED_PRODUCTS: ReadonlySet<string> = new Set(["agent", "gateway"]);
39
40/** The products the filters offer. */
41export function offeredProducts<T extends { key: string }>(products: readonly T[]): T[] {
42 return products.filter((product) => !RETIRED_PRODUCTS.has(product.key));
43}
44
Usage, Billing settings and prepaid AI credit; fixes from the UX audit45/** The periods the page offers. Billing cycles are calendar months (UTC). */
46export const PERIODS = {
47 cycle: "Current billing cycle",
48 last_cycle: "Last billing cycle",
49 "7d": "Last 7 days",
50 "30d": "Last 30 days",
51 "90d": "Last 90 days",
52 custom: "Custom range",
53} as const;
54export type Period = keyof typeof PERIODS;
55
56export type Grain = "day" | "week" | "month";
57export type GroupBy = "product" | "project" | "day";
58
59function day(at: Date): string {
60 return at.toISOString().slice(0, 10);
61}
62
63function isDay(text: string | null | undefined): text is string {
64 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;
65}
66
67/** The days a period covers, both included, as `YYYY-MM-DD` (UTC), and the period actually used. */
68export function resolveRange(
69 asked: string | null | undefined,
70 custom: { from?: string | null; until?: string | null },
71 now = new Date(),
72): { period: Period; from: string; until: string } {
73 const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
74 const period: Period = asked && asked in PERIODS ? (asked as Period) : "cycle";
75 if (period === "custom" && isDay(custom.from) && isDay(custom.until)) {
76 const [from, until] = custom.from <= custom.until ? [custom.from, custom.until] : [custom.until, custom.from];
77 // At most 400 days, as billing reads them.
78 const earliest = day(new Date(Date.parse(`${until}T00:00:00Z`) - 399 * DAY_MS));
79 return { period, from: from < earliest ? earliest : from, until };
80 }
81 if (period === "last_cycle") {
82 const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1));
83 const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0));
84 return { period, from: day(start), until: day(end) };
85 }
86 if (period === "7d" || period === "30d" || period === "90d") {
87 const days = Number(period.slice(0, -1));
88 return { period, from: day(new Date(today.getTime() - (days - 1) * DAY_MS)), until: day(today) };
89 }
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.90 // 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.91 return { period: "cycle", ...monthSpan(today) };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit92}
93
94/** `Oct 1 – Oct 8, 2026`. */
95export function rangeLabel(from: string, until: string): string {
96 const f = new Date(`${from}T00:00:00Z`);
97 const u = new Date(`${until}T00:00:00Z`);
98 const short = (d: Date, year: boolean) =>
99 d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC", ...(year ? { year: "numeric" } : {}) });
100 return from === until ? short(u, true) : `${short(f, f.getUTCFullYear() !== u.getUTCFullYear())} – ${short(u, true)}`;
101}
102
103/** Every day from `from` to `until`, both included. */
104export function daysIn(from: string, until: string): string[] {
105 const out: string[] = [];
106 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)));
107 return out;
108}
109
110/** One column of the chart: a day, a week (from its Monday) or a month, with each product's part. */
111export type Column = { key: string; label: string; from: string; until: string; parts: Record<string, number>; total: number };
112
113function columnKey(d: string, grain: Grain): string {
114 if (grain === "month") return d.slice(0, 7);
115 if (grain === "week") {
116 const t = new Date(`${d}T00:00:00Z`);
117 const monday = new Date(t.getTime() - ((t.getUTCDay() + 6) % 7) * DAY_MS);
118 return day(monday);
119 }
120 return d;
121}
122
123function columnLabel(key: string, grain: Grain): string {
124 if (grain === "month") return new Date(`${key}-01T00:00:00Z`).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
125 return new Date(`${key}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
126}
127
128/**
129 * The chart's columns: every day (or week, or month) of the range, zeros
130 * included, each product's usage at price. Cumulative adds each column to
131 * the ones before, product by product.
132 */
133export function columns(days: UsageDay[], from: string, until: string, grain: Grain = "day", cumulative = false): Column[] {
134 const out: Column[] = [];
135 const at = new Map<string, Column>();
136 for (const d of daysIn(from, until)) {
137 const key = columnKey(d, grain);
138 let column = at.get(key);
139 if (!column) {
140 column = { key, label: columnLabel(key, grain), from: d, until: d, parts: {}, total: 0 };
141 at.set(key, column);
142 out.push(column);
143 }
144 column.until = d;
145 }
146 for (const d of days) {
147 const column = at.get(columnKey(d.day, grain));
148 if (!column) continue;
149 column.parts[d.product] = (column.parts[d.product] ?? 0) + d.micros;
150 column.total += d.micros;
151 }
152 if (cumulative) {
153 const running: Record<string, number> = {};
154 for (const column of out) {
155 for (const [product, micros] of Object.entries(column.parts)) running[product] = (running[product] ?? 0) + micros;
156 column.parts = { ...running };
157 column.total = Object.values(running).reduce((a, b) => a + b, 0);
158 }
159 }
160 return out;
161}
162
163/** The finest grain that keeps the chart readable: days up to 45 of them, weeks up to 200, months past that. */
164export function defaultGrain(from: string, until: string): Grain {
165 const days = daysIn(from, until).length;
166 return days <= 45 ? "day" : days <= 200 ? "week" : "month";
167}
168
169/**
170 * Clean ticks for a money axis from 0 to at least `max` micros: 1, 2 or 5
171 * times a power of ten, every label different. With nothing used, one tick
172 * at $0.
173 */
174export function ticks(max: number, count = 4): number[] {
175 if (!(max > 0)) return [0];
176 const raw = max / count;
177 const power = 10 ** Math.floor(Math.log10(raw));
178 const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? 10 * power;
179 const out: number[] = [];
180 for (let v = 0; v < max + step / 2 && out.length <= count + 1; v += step) out.push(Math.round(v));
181 if (out[out.length - 1]! < max) out.push(Math.round(out[out.length - 1]! + step));
182 return out;
183}
184
185function compact(n: number): string {
186 if (n >= 1e9) return `${(n / 1e9).toLocaleString("en-US", { maximumFractionDigits: 1 })}B`;
187 if (n >= 1e6) return `${(n / 1e6).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`;
188 if (n >= 1e4) return `${(n / 1e3).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`;
189 return Math.round(n).toLocaleString("en-US");
190}
191
192/** How much of a meter, in its unit: `1.2M tokens`, `3h 12m`, `504 MB`, `12 entries`. */
193export function quantity(amount: number, unit: string): string {
194 switch (unit) {
195 case "tokens":
196 return `${compact(amount)} tokens`;
197 case "seconds": {
198 const s = Math.round(amount);
199 const h = Math.floor(s / 3600);
200 const m = Math.floor((s % 3600) / 60);
201 return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
202 }
203 case "bytes":
204 return bytes(amount);
205 case "operations":
206 return `${compact(amount)} ${amount === 1 ? "operation" : "operations"}`;
207 case "requests":
208 return `${compact(amount)} ${amount === 1 ? "request" : "requests"}`;
209 case "micros":
210 return money(amount);
211 default:
212 return `${compact(amount)} ${amount === 1 ? "entry" : "entries"}`;
213 }
214}
215
216/** Storage in powers of ten, as it is priced: `504 MB`, `1 TB`. */
217export function bytes(n: number): string {
218 const units: [number, string][] = [
219 [1e12, "TB"],
220 [1e9, "GB"],
221 [1e6, "MB"],
222 [1e3, "KB"],
223 ];
224 for (const [size, name] of units) {
225 if (n >= size) return `${(n / size).toLocaleString("en-US", { maximumFractionDigits: n / size >= 100 ? 0 : 1 })} ${name}`;
226 }
227 return `${Math.round(n)} B`;
228}
229
230/** What paid for usage, in order, each line with anything to show: the receipt under the totals. */
231export function receipt(totals: UsageTotals, discountPercent: number | null | undefined): { label: string; micros: number; minus: boolean }[] {
232 const lines: { label: string; micros: number; minus: boolean }[] = [{ label: "Usage at price", micros: totals.priceMicros, minus: false }];
233 if (totals.discountMicros > 0) lines.push({ label: `Discount${discountPercent ? ` (${discountPercent}%)` : ""}`, micros: totals.discountMicros, minus: true });
234 if (totals.includedMicros > 0) lines.push({ label: "Included usage and pools", micros: totals.includedMicros, minus: true });
235 if (totals.creditsMicros > 0) lines.push({ label: "Credits applied", micros: totals.creditsMicros, minus: true });
236 lines.push({ label: "Charged", micros: totals.chargedMicros, minus: false });
237 return lines;
238}
239
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.240/**
241 * The note under the receipt: what is metered this month and not yet
242 * closed, with the fraction of a cent it carries (it explains a gap of
243 * one), and what of it the close will charge on the account's terms. An
244 * older billing says only that it is charged at the close.
245 */
246export function pendingSentence(totals: Pick<UsageTotals, "pendingMicros" | "pendingChargedMicros">): string {
247 const pending = money(totals.pendingMicros, { precise: true });
248 const charged = totals.pendingChargedMicros;
249 if (charged == null) return `${pending} of it is metered this month and not yet closed; it is charged when the month closes.`;
250 const part = charged <= 0 ? "nothing" : charged >= totals.pendingMicros ? "all of it" : `${money(charged, { precise: true })} of it`;
251 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.`;
252}
253
Usage, Billing settings and prepaid AI credit; fixes from the UX audit254/** Rows of the breakdown when grouped by project: each project's usage across every meter. */
255export function byProject(report: Pick<UsageReport, "products">): { project: string; micros: number; meters: { label: string; micros: number }[] }[] {
256 const rows = new Map<string, { project: string; micros: number; meters: { label: string; micros: number }[] }>();
257 for (const product of report.products) {
258 for (const meter of product.meters) {
259 for (const part of meter.byProject) {
260 if (part.micros === 0) continue;
261 const row = rows.get(part.project) ?? { project: part.project, micros: 0, meters: [] };
262 row.micros += part.micros;
263 row.meters.push({ label: meter.label, micros: part.micros });
264 rows.set(part.project, row);
265 }
266 }
267 }
268 return [...rows.values()].sort((a, b) => b.micros - a.micros);
269}
270
271/** The report as CSV: one row per meter, day and amount at price. */
272export function usageCsv(report: Pick<UsageReport, "from" | "until" | "products">): string {
273 const days = daysIn(report.from, report.until);
274 const quote = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
275 const lines = ["day,product,meter,usd"];
276 for (const product of report.products) {
277 for (const meter of product.meters) {
278 meter.daily.forEach((micros, i) => {
279 if (micros !== 0) lines.push([days[i] ?? "", quote(product.label), quote(meter.label), (micros / MICROS_PER_DOLLAR).toFixed(6)].join(","));
280 });
281 if ((meter.pendingMicros ?? 0) !== 0) {
282 lines.push(["pending", quote(product.label), quote(meter.label), ((meter.pendingMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6)].join(","));
283 }
284 }
285 }
286 return `${lines.join("\n")}\n`;
287}
288
289/** The meters worth a row: anything used, and those with an allowance. */
290export function shownLines(meters: MeterLine[]): MeterLine[] {
291 return meters.filter((m) => m.micros !== 0 || m.quantity !== 0 || m.allowance);
292}
293
294/** Who sees test-mode hints: g1t's own people (members of Flagon's workspace). */
295export const STAFF_WORKSPACE = "flagon-io";
296export function isStaff(viewer: { workspaces?: { slug: string }[] } | null | undefined): boolean {
297 return !!viewer?.workspaces?.some((w) => w.slug === STAFF_WORKSPACE);
298}