Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

6 commits

55 files+1571−720/55 viewed
+1−2
11 {
2− "recommendations": [
3− ]
2+ "recommendations": ["astro-build.astro-vscode"]
43 }
+20−3
7575
7676 ## How long it is kept
7777
78−The log can be read and exported back **90 days**, on every workspace,
79−with or without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan).
80−Entries older than 90 days are removed.
78+How far back the log goes depends on the workspace's plan:
79+
80+| Workspace | Kept |
81+| --- | --- |
82+| Free | **7 days** |
83+| On the [g1t plan](/guides/usage-and-billing/#the-g1t-plan) | **90 days** |
84+| Paid for by an [enterprise](/guides/usage-and-billing/#enterprises-and-custom-terms) | **90 days** |
85+| Longer, by arrangement | Up to **400 days** |
86+
87+The log can be read and exported back that far, and no further. Once a
88+day, entries older than that are **deleted**, and cannot be brought back:
89+export what you need to keep before then. Starting the plan keeps 90 days
90+from then on; entries already deleted stay deleted. Ending it goes back to
91+7 days, and the next daily pass deletes what is older.
92+
93+For a longer log, such as for a compliance requirement, email
94+[support@g1t.sh](mailto:support@g1t.sh). g1t can set the workspace's
95+account to keep up to 400 days, and what is set there takes the place of
96+the plan's. A [self-hosted](/guides/self-hosting/) g1t that does not charge
97+keeps 90 days for every workspace.
8198
8299 ## Export
83100
+5−0
112112 work is routed to, translates if the provider speaks OpenAI's API, and
113113 forwards the request. Answers stream straight back.
114114
115+As each answer passes, the proxy reads how many tokens it used (input,
116+output, and cache reads and writes) and counts them for the run, under the
117+person it was for. Those counts are for usage views; they never change what
118+a run is charged.
119+
115120 The token stops working within seconds of the run finishing, however it
116121 ends, and within seconds if you disconnect the provider. A run whose end
117122 g1t never hears about loses it three hours after it starts. Keys are sealed when you save them, and used
+11−3
2020 - [Security scans](#storage-search-embeddings-and-scans) of history and
2121 dependencies, which g1t pays for on a free workspace.
2222 - Search and Explore.
23−- The [audit log](/guides/audit-log/), kept 90 days, with export.
23+- The [audit log](/guides/audit-log/), with export: kept 7 days, or 90 on
24+ the plan.
2425 - Your own agent through [MCP](/reference/mcp/).
2526 - 1 GB of private repository storage, and 50,000
2627 [git operations](#git-operations) a month.
5253 - **Unlimited** projects, previews and repositories.
5354 - Agents, workflows, the merge queue,
5455 [deployments](/guides/deployments/) and semantic search.
56+- **90 days** of [audit log](/guides/audit-log/#how-long-it-is-kept), in
57+ place of a free workspace's 7.
5558 - Usage past $10 is charged at cost plus 20%, **up to your
5659 [spend limit](#limits)**.
5760
576579 ## Security on every plan
577580
578581 Security is never a paid extra. Every workspace, free or on the plan, has
579−the same [audit log](/guides/audit-log/), kept 90 days, and the same CSV
580−and JSON export. Single sign-on through your identity provider is not
582+the [audit log](/guides/audit-log/), with the same CSV and JSON export,
583+and secret push protection. What the plan changes is how long the log is
584+kept: [7 days free, 90 on the plan](/guides/audit-log/#how-long-it-is-kept),
585+and longer by arrangement. Single sign-on through your identity provider is not
581586 built yet; when it is, it will be on every plan.
582587
583588 ## Enterprises and custom terms
597602 stays accurate.
598603 - **Custom**: a discount on usage, a limit of its own, or a larger share
599604 of the pools, sometimes until a date.
605+- **A longer audit log**: up to 400 days for every workspace the account
606+ pays for, in place of the plan's 7 or 90. See
607+ [how long it is kept](/guides/audit-log/#how-long-it-is-kept).
600608
601609 g1t's own workspaces and those of Flagon, Inc., the company that makes g1t,
602610 run comped. Their usage is recorded at cost, apart from what customers
+1−1
193193 | Webhooks, integrations, secrets and variables | The workspace's own are removed. |
194194 | Memory and guardrails | The workspace's own are removed. |
195195 | Statements, invoices and the ledger | Kept, for accounting. |
196−| The audit log | Kept for its usual [90 days](/guides/audit-log/#how-long-it-is-kept), with the deletion as its last entry. With no owners left, ask support@g1t.sh for an export. |
196+| The audit log | Kept as [long as its account keeps it](/guides/audit-log/#how-long-it-is-kept), with the deletion as its last entry: once the plan ends with the workspace, that is 7 days, unless an enterprise pays for it or longer was arranged. With no owners left, ask support@g1t.sh for an export. |
197197 | Recently deleted repositories | Purged with it, their git data with them. |
198198 | Old addresses | Redirects for repositories transferred out keep working. The workspace's own pages answer 404. |
199199
+13−1
284284 }
285285 if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`);
286286 if (allowances.issueCapMicros != null) lines.push(`Issue cap: ${usd(allowances.issueCapMicros)} an issue`);
287+ if (allowances.auditRetentionDays != null) lines.push(`Audit log: ${allowances.auditRetentionDays} days, in place of the plan's`);
287288 if (allowances.hold) lines.push(`Held: ${allowances.hold}`);
288289 return lines;
289290 }
291292 /**
292293 * The g1t plan without its price, the account's share of g1t's pools, and
293294 * staff's overrides of what owners set: agents at once, the run and issue
294− * caps, and a hold on new compute. Comped accounts have the plan anyway.
295+ * caps, the days of audit log kept (such as for an organization that pays
296+ * for longer), and a hold on new compute. Comped accounts have the plan
297+ * anyway.
295298 */
296299 export function AllowancesForm({
297300 allowances,
365368 <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10.">
366369 <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} />
367370 </Field>
371+ <Field label="Audit log days" hint="Kept, in place of the plan's, longer or shorter, up to 400. Blank: the plan's (7 free, 90 on the plan).">
372+ <Input
373+ name="auditDays"
374+ inputMode="numeric"
375+ pattern="\d{1,3}"
376+ placeholder="Plan's"
377+ defaultValue={values?.auditDays ?? (current.auditRetentionDays != null ? String(current.auditRetentionDays) : "")}
378+ />
379+ </Field>
368380 <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold.">
369381 <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} />
370382 </Field>
+19−2
102102 test("allowances: the plan without its price, pool shares, and staff's overrides", () => {
103103 assert.deepEqual(parseAllowances(form({})), {
104104 ok: true,
105− value: { plan: false, ossRepoMicros: null, trialMicros: null, maxConcurrentAgents: null, runCapMicros: null, issueCapMicros: null, hold: null },
105+ value: {
106+ plan: false,
107+ ossRepoMicros: null,
108+ trialMicros: null,
109+ maxConcurrentAgents: null,
110+ runCapMicros: null,
111+ issueCapMicros: null,
112+ auditRetentionDays: null,
113+ hold: null,
114+ },
106115 });
107116 assert.deepEqual(
108− parseAllowances(form({ plan: "on", oss: "5", trial: "2.50", agents: "20", runCap: "25", issueCap: "500", hold: " Card disputed;\nwaiting on the bank " })),
117+ parseAllowances(form({ plan: "on", oss: "5", trial: "2.50", agents: "20", runCap: "25", issueCap: "500", auditDays: "365", hold: " Card disputed;\nwaiting on the bank " })),
109118 {
110119 ok: true,
111120 value: {
115124 maxConcurrentAgents: 20,
116125 runCapMicros: 25_000_000,
117126 issueCapMicros: 500_000_000,
127+ auditRetentionDays: 365,
118128 hold: "Card disputed; waiting on the bank",
119129 },
120130 },
127137 assert.equal(parseAllowances(form({ runCap: "0.05" })).ok, false);
128138 assert.equal(parseAllowances(form({ runCap: "1000.01" })).ok, false);
129139 assert.equal(parseAllowances(form({ issueCap: "10000.01" })).ok, false);
140+ // Audit log days: shorter or longer than the plan's, up to 400.
141+ assert.equal((parseAllowances(form({ auditDays: "3" })) as { value: { auditRetentionDays: number } }).value.auditRetentionDays, 3);
142+ assert.equal((parseAllowances(form({ auditDays: "400" })) as { value: { auditRetentionDays: number } }).value.auditRetentionDays, 400);
143+ assert.equal(parseAllowances(form({ auditDays: "0" })).ok, false);
144+ assert.equal(parseAllowances(form({ auditDays: "401" })).ok, false);
145+ assert.equal(parseAllowances(form({ auditDays: "30.5" })).ok, false);
146+ assert.equal(parseAllowances(form({ auditDays: "a year" })).ok, false);
130147 assert.equal(parseAllowances(form({ hold: "x".repeat(201) })).ok, false);
131148 });
132149
+19−3
184184 /** Staff's overrides of the owners' caps: one run, and one issue's agents in all. */
185185 export const MAX_RUN_CAP_MICROS = 1_000 * MICROS_PER_DOLLAR;
186186 export const MAX_ISSUE_CAP_MICROS = 10_000 * MICROS_PER_DOLLAR;
187+/**
188+ * The most days of audit log staff can give one account: billing's
189+ * AUDIT_MAX_DAYS. The events service deletes anything older for everyone.
190+ */
191+export const MAX_AUDIT_DAYS = 400;
187192 /** The smallest cap: ten cents, as owners may set. */
188193 const MIN_CAP_MICROS = 100_000;
189194 const MAX_HOLD = 200;
191196 /**
192197 * Allowances from the plan-and-pools form: the g1t plan without its price,
193198 * the account's share of the open-source pool and of trials, and staff's
194− * overrides of agents at once, the run cap and the issue cap. A blank
195− * amount means the default (for a cap, no override). A hold is a line
196− * saying why new compute is held; blank is no hold.
199+ * overrides of agents at once, the run cap, the issue cap and the days of
200+ * audit log kept. A blank amount means the default (for a cap, no
201+ * override; for the audit log, the plan's). A hold is a line saying why
202+ * new compute is held; blank is no hold.
197203 */
198204 export function parseAllowances(form: FormData): Parsed<Allowances> {
199205 const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => {
223229 maxConcurrentAgents = Number(rawAgents);
224230 }
225231
232+ let auditRetentionDays: number | null = null;
233+ const rawAudit = text(form, "auditDays");
234+ if (rawAudit) {
235+ if (!/^\d{1,3}$/.test(rawAudit) || Number(rawAudit) < 1 || Number(rawAudit) > MAX_AUDIT_DAYS) {
236+ return { ok: false, error: `Audit log days is a whole number from 1 to ${MAX_AUDIT_DAYS}, or blank for the plan's.` };
237+ }
238+ auditRetentionDays = Number(rawAudit);
239+ }
240+
226241 const hold = text(form, "hold").replace(/\s+/g, " ");
227242 if (hold.length > MAX_HOLD) return { ok: false, error: `Keep the hold's reason under ${MAX_HOLD} characters.` };
228243
235250 maxConcurrentAgents,
236251 runCapMicros: runCap.value,
237252 issueCapMicros: issueCap.value,
253+ auditRetentionDays,
238254 hold: hold || null,
239255 },
240256 };
+1−1
538538 ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}`
539539 : "—",
540540 ],
541− ["Audit log", `${e.auditRetentionDays} days`],
541+ ["Audit log", `${e.auditRetentionDays} days${e.auditRetentionCustom ? ", set by staff" : ""}`],
542542 ];
543543 return (
544544 <Section
+1−1
157157 {
158158 icon: <ScrollText size={18} />,
159159 title: "Audit log on every workspace",
160− about: "Every action by people, tokens and agents, with whether it was allowed and the rule that decided. Kept 90 days, with export.",
160+ about: "Every action by people, tokens and agents, with whether it was allowed and the rule that decided. Kept 90 days on the plan and 7 free, with export.",
161161 to: `${DOCS}/guides/audit-log/`,
162162 },
163163 ];
+9−0
3232 import type { ShellData } from "./shell";
3333 import { useLiveRefresh } from "./agents";
3434 import { Unavailable } from "./mission";
35+import { TokenUsagePanel } from "./token-usage";
3536 import { Avatar, TimeAgo } from "./ui";
3637 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
3738 import type { Loaded } from "../routes/home";
10931094 )}
10941095 </section>
10951096
1097+ {workspace && (
1098+ <TokenUsagePanel
1099+ workspace={loaderData.tokens.workspace}
1100+ mine={loaderData.tokens.mine}
1101+ usageHref={`/${workspace}/-/usage`}
1102+ />
1103+ )}
1104+
10961105 <section id="activity" className="scroll-mt-20 rounded-xl border border-line bg-surface p-5">
10971106 <div className="flex items-center justify-between">
10981107 <h2 className="text-base font-semibold tracking-tight">Activity</h2>
+203−0
1+import { useState } from "react";
2+import { Link } from "react-router";
3+
4+import { cn } from "../lib/cn";
5+import { usd } from "../lib/mission-control";
6+import {
7+ HEAT_LEVELS,
8+ type MixPart,
9+ type TokenUsageView,
10+ bestDay,
11+ cacheShare,
12+ compactTokens,
13+ heatLevel,
14+ shortDay,
15+ tokenMix,
16+} from "../lib/token-usage";
17+
18+/**
19+ * The token mix's colours, in its stacking order. Checked against the dark
20+ * surface (lightness band, chroma, contrast, and neighbours apart for every
21+ * kind of colour vision); change them only by re-running that check.
22+ */
23+const MIX_COLOR: Record<MixPart["key"], string> = {
24+ input: "#9085e9",
25+ cacheRead: "#199e70",
26+ cacheWrite: "#3987e5",
27+ output: "#d55181",
28+};
29+
30+/** The daily grid's single hue, stronger with more tokens. */
31+const HEAT_SHADE = ["var(--color-line)", ...Array.from({ length: HEAT_LEVELS }, (_, i) => `color-mix(in oklab, var(--color-merged) ${[28, 48, 72, 100][i]}%, var(--color-surface))`)];
32+
33+type Scope = "workspace" | "mine";
34+
35+/**
36+ * Model tokens over the last weeks, for the workspace or for you: what was
37+ * used and what it cost, how many days, how much came from the cache, each
38+ * day's intensity, and the mix of input, cache and output.
39+ */
40+export function TokenUsagePanel({
41+ workspace,
42+ mine,
43+ usageHref,
44+}: {
45+ workspace: TokenUsageView | null;
46+ mine: TokenUsageView | null;
47+ usageHref: string | null;
48+}) {
49+ const [scope, setScope] = useState<Scope>("workspace");
50+ const usage = scope === "mine" ? mine : workspace;
51+ return (
52+ <section aria-labelledby="token-usage" className="rounded-xl border border-line bg-surface p-5">
53+ <div className="flex items-center justify-between gap-3">
54+ <h2 id="token-usage" className="text-base font-semibold tracking-tight">
55+ Usage
56+ </h2>
57+ <div role="tablist" aria-label="Whose usage" className="flex rounded-md border border-line p-0.5 text-xs">
58+ {(["workspace", "mine"] as const).map((option) => (
59+ <button
60+ key={option}
61+ type="button"
62+ role="tab"
63+ aria-selected={scope === option}
64+ disabled={option === "mine" && !mine}
65+ onClick={() => setScope(option)}
66+ className={cn(
67+ "rounded px-2 py-0.5 transition-colors disabled:opacity-40",
68+ scope === option ? "bg-raised text-fg" : "text-muted hover:text-fg",
69+ )}
70+ >
71+ {option === "workspace" ? "Workspace" : "You"}
72+ </button>
73+ ))}
74+ </div>
75+ </div>
76+ {usage ? <Figures usage={usage} usageHref={usageHref} /> : <p className="mt-4 text-sm text-muted">Usage could not be loaded.</p>}
77+ </section>
78+ );
79+}
80+
81+function Figures({ usage, usageHref }: { usage: TokenUsageView; usageHref: string | null }) {
82+ const share = cacheShare(usage);
83+ const weeks = Math.round(usage.days / 7);
84+ if (usage.totalTokens === 0) {
85+ return (
86+ <p className="mt-4 text-sm text-muted">
87+ No model tokens in the last {weeks} weeks{usage.person ? " on work you asked for" : ""}. Agents' runs show here as they work.
88+ </p>
89+ );
90+ }
91+ return (
92+ <>
93+ <dl className="mt-4 grid grid-cols-2 gap-2">
94+ <Tile label="Tokens" value={compactTokens(usage.totalTokens)} />
95+ <Tile label="Cost" value={usd(usage.costMicros / 1e6)} title="What these runs cost, whoever paid" />
96+ <Tile label="Active days" value={String(usage.activeDays)} hint={`of ${usage.days}`} />
97+ <Tile label="From cache" value={share == null ? "—" : `${Math.round(share * 100)}%`} title="Prompt tokens read from the model's cache" />
98+ </dl>
99+ <DailyGrid byDay={usage.byDay} />
100+ <Mix usage={usage} />
101+ {usageHref && (
102+ <Link to={usageHref} className="mt-4 block border-t border-line pt-3 text-xs text-muted hover:text-fg">
103+ Every run, by repository and model
104+ </Link>
105+ )}
106+ </>
107+ );
108+}
109+
110+function Tile({ label, value, hint, title }: { label: string; value: string; hint?: string; title?: string }) {
111+ return (
112+ <div className="rounded-lg border border-line bg-bg/40 px-3 py-2.5" title={title}>
113+ <dt className="text-[0.6875rem] text-muted">{label}</dt>
114+ <dd className="mt-0.5 text-lg font-semibold tracking-tight tabular-nums">
115+ {value}
116+ {hint && <span className="ml-1 text-xs font-normal text-faint">{hint}</span>}
117+ </dd>
118+ </div>
119+ );
120+}
121+
122+/**
123+ * Each day as a cell, two rows, oldest first; shaded by quartile of the
124+ * busiest day. Hover or focus a day to read it.
125+ */
126+function DailyGrid({ byDay }: { byDay: TokenUsageView["byDay"] }) {
127+ const best = bestDay(byDay);
128+ const busiest = best?.tokens ?? 0;
129+ const [shown, setShown] = useState<{ day: string; tokens: number } | null>(null);
130+ const columns = Math.ceil(byDay.length / 2);
131+ const readout = shown ?? best;
132+ return (
133+ <div className="mt-5">
134+ <div className="flex items-baseline justify-between gap-2">
135+ <h3 className="text-xs font-medium text-fg-soft">Each day</h3>
136+ <p className="text-[0.6875rem] text-muted tabular-nums" aria-live="polite">
137+ {readout ? `${shown ? "" : "Busiest: "}${shortDay(readout.day)} · ${compactTokens(readout.tokens)}` : ""}
138+ </p>
139+ </div>
140+ <div
141+ className="mt-2 grid gap-[3px]"
142+ style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
143+ onMouseLeave={() => setShown(null)}
144+ >
145+ {byDay.map((entry) => (
146+ <span
147+ key={entry.day}
148+ role="img"
149+ tabIndex={0}
150+ aria-label={`${shortDay(entry.day)}: ${compactTokens(entry.tokens)} tokens`}
151+ onMouseEnter={() => setShown(entry)}
152+ onFocus={() => setShown(entry)}
153+ onBlur={() => setShown(null)}
154+ className="aspect-square rounded-[3px] outline-offset-1 hover:ring-1 hover:ring-fg-soft focus-visible:outline-2 focus-visible:outline-accent"
155+ style={{ background: HEAT_SHADE[heatLevel(entry.tokens, busiest)] }}
156+ />
157+ ))}
158+ </div>
159+ <div className="mt-1.5 flex items-center justify-between text-[0.6875rem] text-faint tabular-nums">
160+ <span>{byDay[0] ? shortDay(byDay[0].day) : ""}</span>
161+ <span className="flex items-center gap-1" aria-hidden>
162+ Less
163+ {HEAT_SHADE.map((shade) => (
164+ <span key={shade} className="size-2 rounded-[2px]" style={{ background: shade }} />
165+ ))}
166+ More
167+ </span>
168+ <span>{byDay.length ? shortDay(byDay[byDay.length - 1].day) : ""}</span>
169+ </div>
170+ </div>
171+ );
172+}
173+
174+/** Input, cache reads, cache writes and output as one bar, each part labelled. */
175+function Mix({ usage }: { usage: TokenUsageView }) {
176+ const parts = tokenMix(usage).filter((part) => part.tokens > 0);
177+ return (
178+ <div className="mt-5">
179+ <h3 className="text-xs font-medium text-fg-soft">Token mix</h3>
180+ <div className="mt-2 flex h-2.5 gap-[2px] overflow-hidden rounded" role="img" aria-label={parts.map((p) => `${p.label} ${Math.round(p.share * 100)}%`).join(", ")}>
181+ {parts.map((part) => (
182+ <span
183+ key={part.key}
184+ title={`${part.label}: ${compactTokens(part.tokens)} (${Math.round(part.share * 100)}%)`}
185+ className="h-full first:rounded-l last:rounded-r"
186+ style={{ width: `${part.share * 100}%`, minWidth: 3, background: MIX_COLOR[part.key] }}
187+ />
188+ ))}
189+ </div>
190+ <ul className="mt-2.5 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[0.6875rem]">
191+ {tokenMix(usage).map((part) => (
192+ <li key={part.key} className="flex min-w-0 items-center gap-1.5">
193+ <span className="size-2 shrink-0 rounded-[2px]" style={{ background: MIX_COLOR[part.key] }} />
194+ <span className="truncate text-muted">{part.label}</span>
195+ <span className="ml-auto text-fg-soft tabular-nums">
196+ {compactTokens(part.tokens)} <span className="text-faint">{Math.round(part.share * 100)}%</span>
197+ </span>
198+ </li>
199+ ))}
200+ </ul>
201+ </div>
202+ );
203+}
+3−2
1111 export const audit = auditClient(instrumented("events", env.EVENTS));
1212
1313 /**
14− * How many days of the workspace's log are kept: 90, the same on every
15− * plan. Null when billing cannot say, and then nothing is held back.
14+ * How many days of the workspace's log are kept: 7 for a free workspace,
15+ * 90 on the plan, or what g1t staff set for its account. Null when billing
16+ * cannot say, and then nothing is held back.
1617 */
1718 export async function auditRetention(workspace: string): Promise<number | null> {
1819 const found = await billing.entitlements(workspace.toLowerCase()).catch(() => null);
+2−1
9393
9494 test("the log reads back only as far as the plan keeps it", () => {
9595 const now = Date.parse("2026-10-31T00:00:00.000Z");
96− // 90 days, on every plan.
96+ // 90 days on the plan, 7 free.
9797 assert.equal(retainedSince(null, 90, now), "2026-08-02T00:00:00.000Z");
98+ assert.equal(retainedSince(null, 7, now), "2026-10-24T00:00:00.000Z");
9899 assert.equal(retainedSince(null, 30, now), "2026-10-01T00:00:00.000Z");
99100 assert.equal(retainedSince("2026-01-01T00:00:00.000Z", 30, now), "2026-10-01T00:00:00.000Z");
100101 // A later start than the window is kept.
+2−2
2626
2727 /**
2828 * The earliest time a workspace's log can be read from, given how many
29− * days are kept (90 on every plan): the later of what was asked
30− * for and the start of the window.
29+ * days are kept (7 free, 90 on the plan, or what staff set): the later of
30+ * what was asked for and the start of the window.
3131 */
3232 export function retainedSince(since: string | null | undefined, days: number, now = Date.now()): string {
3333 const start = new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
+58−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { bestDay, cacheShare, compactTokens, heatLevel, shortDay, tokenMix, type TokenUsageView } from "./token-usage.ts";
5+
6+const usage = (over: Partial<TokenUsageView> = {}): TokenUsageView => ({
7+ since: "2026-08-26",
8+ days: 42,
9+ person: null,
10+ totalTokens: 0,
11+ inputTokens: 0,
12+ outputTokens: 0,
13+ cacheReadTokens: 0,
14+ cacheWriteTokens: 0,
15+ costMicros: 0,
16+ activeDays: 0,
17+ byDay: [],
18+ ...over,
19+});
20+
21+test("token counts read short", () => {
22+ assert.equal(compactTokens(8_500_000_000), "8.5B");
23+ assert.equal(compactTokens(412_300_000), "412M");
24+ assert.equal(compactTokens(12_340), "12.3K");
25+ assert.equal(compactTokens(2_000_000), "2M");
26+ assert.equal(compactTokens(940), "940");
27+ assert.equal(compactTokens(0), "0");
28+});
29+
30+test("cache share is reads over every prompt token", () => {
31+ assert.equal(cacheShare(usage({ inputTokens: 10, cacheReadTokens: 80, cacheWriteTokens: 10 })), 0.8);
32+ assert.equal(cacheShare(usage()), null);
33+});
34+
35+test("the grid shades by quartile of the busiest day", () => {
36+ assert.equal(heatLevel(0, 100), 0);
37+ assert.equal(heatLevel(1, 100), 1);
38+ assert.equal(heatLevel(26, 100), 2);
39+ assert.equal(heatLevel(100, 100), 4);
40+ assert.equal(heatLevel(5, 0), 0);
41+});
42+
43+test("the best day, and none on an empty window", () => {
44+ assert.deepEqual(bestDay([{ day: "a", tokens: 3 }, { day: "b", tokens: 9 }, { day: "c", tokens: 9 }]), { day: "b", tokens: 9 });
45+ assert.equal(bestDay([{ day: "a", tokens: 0 }]), null);
46+});
47+
48+test("the mix is in its checked order and adds up to one", () => {
49+ const mix = tokenMix(usage({ inputTokens: 1, cacheReadTokens: 6, cacheWriteTokens: 1, outputTokens: 2 }));
50+ assert.deepEqual(mix.map((part) => part.key), ["input", "cacheRead", "cacheWrite", "output"]);
51+ assert.equal(mix.reduce((sum, part) => sum + part.share, 0), 1);
52+ assert.ok(tokenMix(usage()).every((part) => part.share === 0));
53+});
54+
55+test("days are shown short, in UTC", () => {
56+ assert.equal(shortDay("2026-09-22"), "22 Sept");
57+ assert.equal(shortDay("nope"), "nope");
58+});
+74−0
1+/**
2+ * Model tokens for mission control's usage panel: a person's or the
3+ * workspace's, over the last weeks (billing's `token_usage`).
4+ */
5+
6+import type { TokenUsage } from "@g1t/contracts";
7+
8+/** What billing answers. */
9+export type TokenUsageView = TokenUsage;
10+
11+/** "8.5B", "412M", "12.3K", "940". */
12+export function compactTokens(n: number): string {
13+ const units: [number, string][] = [
14+ [1e12, "T"],
15+ [1e9, "B"],
16+ [1e6, "M"],
17+ [1e3, "K"],
18+ ];
19+ for (const [size, unit] of units) {
20+ if (n >= size) {
21+ const value = n / size;
22+ return `${value >= 100 ? Math.round(value) : Number(value.toFixed(1))}${unit}`;
23+ }
24+ }
25+ return String(Math.max(0, Math.round(n)));
26+}
27+
28+/** The share of prompt tokens read from the provider's cache, 0..1; null with no prompt. */
29+export function cacheShare(usage: Pick<TokenUsageView, "inputTokens" | "cacheReadTokens" | "cacheWriteTokens">): number | null {
30+ const prompt = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
31+ return prompt > 0 ? usage.cacheReadTokens / prompt : null;
32+}
33+
34+/** Shading levels for the daily grid: 0 for none, then 1..4 by quartile of the busiest day. */
35+export const HEAT_LEVELS = 4;
36+
37+export function heatLevel(tokens: number, busiest: number): number {
38+ if (tokens <= 0 || busiest <= 0) return 0;
39+ return Math.min(HEAT_LEVELS, Math.max(1, Math.ceil((tokens / busiest) * HEAT_LEVELS)));
40+}
41+
42+/** The busiest day, or null when none had tokens. */
43+export function bestDay(byDay: TokenUsageView["byDay"]): { day: string; tokens: number } | null {
44+ let best: { day: string; tokens: number } | null = null;
45+ for (const entry of byDay) if (entry.tokens > 0 && (!best || entry.tokens > best.tokens)) best = entry;
46+ return best;
47+}
48+
49+/** One part of the token mix, in stacking order. */
50+export type MixPart = { key: "input" | "cacheRead" | "cacheWrite" | "output"; label: string; tokens: number; share: number };
51+
52+/**
53+ * New input, cache reads, cache writes and output, in that order: the
54+ * order the colours were checked in (neighbours stay apart for every kind
55+ * of colour vision).
56+ */
57+export function tokenMix(usage: TokenUsageView): MixPart[] {
58+ const parts: Omit<MixPart, "share">[] = [
59+ { key: "input", label: "New input", tokens: usage.inputTokens },
60+ { key: "cacheRead", label: "Cache reads", tokens: usage.cacheReadTokens },
61+ { key: "cacheWrite", label: "Cache writes", tokens: usage.cacheWriteTokens },
62+ { key: "output", label: "Output", tokens: usage.outputTokens },
63+ ];
64+ const total = parts.reduce((sum, part) => sum + part.tokens, 0);
65+ return parts.map((part) => ({ ...part, share: total > 0 ? part.tokens / total : 0 }));
66+}
67+
68+/** "22 Sept"-style short day, read in UTC since days are UTC dates. */
69+export function shortDay(day: string): string {
70+ const date = new Date(`${day}T00:00:00Z`);
71+ return Number.isNaN(date.getTime())
72+ ? day
73+ : date.toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" });
74+}
+5−1
202202 );
203203 });
204204
205− const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations] = await Promise.all([
205+ const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations, tokens, myTokens] = await Promise.all([
206206 reposP,
207207 soft("projects", perRepoP),
208208 soft("pulls", work.listActivePulls(viewer)),
215215 slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
216216 // Repositories someone has invited the viewer to.
217217 soft("invitations", identity.myRepoInvitations(viewer)),
218+ // Model tokens over the last six weeks: the workspace's, and yours.
219+ slug ? soft("tokens", billing.tokenUsage(slug, viewer)) : null,
220+ slug ? soft("my_tokens", billing.tokenUsage(slug, viewer, { person: username })) : null,
218221 ]);
219222
220223 const repoList = repos ?? [];
598601 weekCost,
599602 },
600603 week,
604+ tokens: { workspace: okOr(tokens), mine: okOr(myTokens) },
601605 groups,
602606 titles: shownTitles,
603607 // A run that is going makes the page worth refreshing on its own.
+6−3
4949 ossPoolMicros: 25_000_000,
5050 ossRepoMicros: 2_000_000,
5151 freePrivateStorageBytes: 1_000_000_000,
52− auditRetentionDays: 90,
52+ auditRetentionDays: 7,
53+ planAuditRetentionDays: 90,
5354 minChargeMicros: 5_000_000,
5455 gitOperationsIncluded: 50_000,
5556 paidStartCeilingMicros: 100_000_000,
143144 {
144145 what: "Audit log",
145146 free: `${tier.auditRetentionDays} days, with export`,
146− plan: `${tier.auditRetentionDays} days, with export`,
147+ plan: `${tier.planAuditRetentionDays} days, with export`,
148+ note: "Longer by arrangement. Older entries are deleted each day.",
147149 },
148150 { what: "Secret push protection", free: "Included", plan: "Included" },
149151 { what: "Single sign-on", free: "On every plan, once it is built", plan: "On every plan, once it is built" },
460462 <li>
461463 <p className="font-medium">Security on every plan</p>
462464 <p className="text-muted">
463− The audit log for {tier.auditRetentionDays} days with export, and secret push protection, for every workspace.
465+ The audit log with export, {tier.planAuditRetentionDays} days or longer by arrangement, and secret push
466+ protection, for every workspace.
464467 </p>
465468 </li>
466469 <li>
+1−1
9191 ? " As an owner you see the whole workspace."
9292 : " As a member you see what was done to the workspace's projects, and what was done by you or on your behalf."}
9393 {retention != null &&
94− ` The log goes back ${retention} days, and exports the same, on every plan.`}
94+ ` The log goes back ${retention} days, and exports the same; older entries are deleted each day.`}
9595 </p>
9696
9797 <Form method="get" className="mt-6 rounded-xl border border-line bg-surface p-4">
+102−3
350350 pub added_micros: i64,
351351 }
352352
353+/// `record_tokens`: what one model answer used, added to the day's count
354+/// for its run. The model proxy sends it after each answer. For usage
355+/// views only: runs are still priced from AI Gateway. Returns
356+/// `Outcome<bool>`: false when there was nothing to count.
357+#[derive(Debug, Serialize, Deserialize)]
358+#[serde(rename_all = "camelCase")]
359+pub struct RecordTokensArgs {
360+ pub workspace: String,
361+ /// The model session's id (`ModelSession::id`), one per run.
362+ pub session: String,
363+ /// The person the run is for, by username. Absent when nobody asked.
364+ #[serde(default)]
365+ pub person: Option<String>,
366+ pub model: String,
367+ /// On g1t's hosted models: `small` or `large`.
368+ #[serde(default)]
369+ pub tier: Option<String>,
370+ #[serde(default)]
371+ pub input: u64,
372+ #[serde(default)]
373+ pub output: u64,
374+ #[serde(default)]
375+ pub cache_read: u64,
376+ #[serde(default)]
377+ pub cache_write: u64,
378+}
379+
380+/// `token_usage`: the model tokens a workspace's runs used, day by day,
381+/// for the whole workspace or for one person. Members only; a member may
382+/// ask only for themselves, an owner for anyone. Returns
383+/// `Outcome<TokenUsage>`.
384+#[derive(Debug, Serialize, Deserialize)]
385+pub struct TokenUsageArgs {
386+ pub workspace: String,
387+ pub viewer: Viewer,
388+ /// A username: only the runs for them.
389+ #[serde(default)]
390+ pub person: Option<String>,
391+ /// How many days, to today: 42 when absent, 366 at most.
392+ #[serde(default)]
393+ pub days: Option<u32>,
394+}
395+
396+/// One day's tokens.
397+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
398+pub struct DayTokens {
399+ /// `YYYY-MM-DD`, UTC.
400+ pub day: String,
401+ pub tokens: u64,
402+}
403+
404+/// The model tokens runs used over a window of days.
405+#[derive(Clone, Debug, Serialize, Deserialize)]
406+#[serde(rename_all = "camelCase")]
407+pub struct TokenUsage {
408+ /// `YYYY-MM-DD`: the first day counted.
409+ pub since: String,
410+ pub days: u32,
411+ /// Null for the whole workspace.
412+ pub person: Option<String>,
413+ pub total_tokens: u64,
414+ pub input_tokens: u64,
415+ pub output_tokens: u64,
416+ pub cache_read_tokens: u64,
417+ pub cache_write_tokens: u64,
418+ /// What those runs were charged, as `usage` measures it.
419+ pub cost_micros: i64,
420+ /// Days in the window with any tokens.
421+ pub active_days: u32,
422+ /// Every day in the window, oldest first, zeros included.
423+ pub by_day: Vec<DayTokens>,
424+}
425+
353426 /// What a workspace pays a monthly price for. There is one plan, `plan`
354427 /// ("g1t"): a flat price per workspace, never per person, with included
355428 /// usage each month, more private storage, and deployments. Never free:
716789 /// it, the plan pays at cost plus the margin; a free workspace's pushes
717790 /// to private repositories stop instead.
718791 pub free_private_storage_bytes: i64,
719− /// Days of audit log, the same on every plan.
792+ /// Days of audit log a free workspace keeps.
720793 pub audit_retention_days: u32,
794+ /// Days of audit log the g1t plan keeps, and g1t's own and enterprise
795+ /// workspaces. Longer is by arrangement, set per account in sudo.
796+ #[serde(default)]
797+ pub plan_audit_retention_days: u32,
721798 /// The smallest amount a card is charged when a month closes; less
722799 /// carries over. Charges at a limit always go through.
723800 pub min_charge_micros: i64,
788865 /// `ISSUE_CAP_MICROS` and the owners' own. None: theirs, or the default.
789866 #[serde(default)]
790867 pub issue_cap_micros: Option<i64>,
868+ /// Days of audit log its workspaces keep, in place of the plan's (7
869+ /// free, 90 on the plan), longer or shorter. None: the plan's.
870+ #[serde(default)]
871+ pub audit_retention_days: Option<u32>,
791872 /// A hold g1t staff put on new compute, with why. None: no hold.
792873 #[serde(default)]
793874 pub hold: Option<String>,
9211002 pub workspace: String,
9221003 }
9231004
1005+/// `audit_retention`: how many days of audit log each workspace keeps, for
1006+/// the events service's daily purge. Takes `AuditRetentionArgs`; returns
1007+/// `Vec<AuditRetention>`, one for each workspace asked about.
1008+#[derive(Debug, Serialize, Deserialize)]
1009+pub struct AuditRetentionArgs {
1010+ pub workspaces: Vec<String>,
1011+}
1012+
1013+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1014+pub struct AuditRetention {
1015+ pub workspace: String,
1016+ pub days: u32,
1017+}
1018+
9241019 #[derive(Clone, Debug, Serialize, Deserialize)]
9251020 #[serde(rename_all = "camelCase")]
9261021 pub struct Entitlements {
9731068 pub included_micros: i64,
9741069 #[serde(default)]
9751070 pub included_used_micros: i64,
976− /// How far back the audit log can be read and exported: the same on
977− /// every plan.
1071+ /// How far back the audit log can be read and exported, and what is
1072+ /// kept: the plan's days, or what g1t staff set for the account.
9781073 pub audit_retention_days: u32,
1074+ /// Whether `audit_retention_days` is what staff set for the account
1075+ /// rather than the plan's.
1076+ #[serde(default)]
1077+ pub audit_retention_custom: bool,
9791078 /// Private repository storage that is free for every workspace: past
9801079 /// it, the plan pays for it and a free workspace's pushes stop.
9811080 pub free_private_storage_bytes: i64,
+8−0
387387 /// For `g1t`: the tier the run was routed to, `small` or `large`.
388388 #[serde(default)]
389389 pub tier: Option<String>,
390+ /// The person the run is for, by username: who asked g1t for the work.
391+ /// Null when nobody did. Never `g1t`, the agent itself.
392+ #[serde(default)]
393+ pub requested_by: Option<String>,
390394 /// For `endpoint`: where to send requests.
391395 pub base_url: Option<String>,
392396 /// For `anthropic` and `endpoint`: the workspace's key.
566570 /// when the run goes to g1t's models.
567571 #[serde(default)]
568572 pub tier: Option<String>,
573+ /// The person the run is for, by username, so usage can be shown per
574+ /// person. Null when nobody asked; `g1t`, the agent, is kept as null.
575+ #[serde(default)]
576+ pub requested_by: Option<String>,
569577 }
570578
571579 /// `routes`: a workspace's model routes, one per kind of work that has its
+8−3
413413 1,000, today's demo-scale traffic alone is about 3.2 million a month (~$480). Ask Cloudflare
414414 (Q1) before 2026-10-14. Either way the volume is mostly waste: every repos call opens a handle
415415 with `get` even when the answer is cached, and the object cache may not be hitting (no hit/miss
416− meter yet). Fixes, ranked: lazy `get`; a real cache for objects named by hash (KV or an
417− in-isolate LRU, with hit/miss meters); Actions reading workflows from the synced table instead
418− of the store on every event; caller attribution in the meters.
416+ meter yet). Done on 2026-10-06: the handle's `get` waits for the first call that needs the
417+ store (an answer from a cache, or `branches` over git, costs none); objects named by hash are
418+ kept in the isolate (16 MB, oldest out first) ahead of the Cache API, and every look is
419+ metered (`cache.memory_hit`, `cache.edge_hit`, `cache.miss`); issue and comment events start
420+ nothing and read nothing when the synced `workflows` table has no workflow listening. Next:
421+ read `cache.edge_hit` against `cache.miss` after a day; if the Cache API never hits from a
422+ Worker reached only by service bindings, put objects in KV instead. Still to do: caller
423+ attribution in the meters.
419424 - 476 client errors on 2026-10-06 are unexplained; the fetch fix below accounts for some (every
420425 failed negotiation was one).
421426
+18−0
200200 emailed again weekly. The red bar on every sudo page shows margin,
201201 overall and leak alerts.
202202
203+## Token usage
204+
205+The model proxy (`services/models`) reads Anthropic's `usage` from every
206+`/v1/messages` answer, streamed or whole, on g1t's models and on a
207+workspace's own provider alike (OpenAI-shaped providers are translated
208+first). Count-tokens requests are not answers and are skipped. After the
209+answer, it calls `record_tokens`, which adds input, output, cache reads and
210+cache writes to one row per day, workspace, person, session and model in
211+`token_usage` (migration `0030_token_usage.sql`). The person is who the run
212+was for, from the model session's `requested_by`; never g1t's agent. A
213+report that fails is dropped and never affects the answer.
214+
215+`token_usage` reads a window (42 days by default, 366 at most) for the
216+workspace or one person: totals, every day's tokens and the active days,
217+with `costMicros` the window's run charges from the ledger, measured as
218+`usage` measures them. These counts are for views only: runs are still
219+priced from AI Gateway's logs, never from `token_usage`.
220+
203221 ## Tables (migration `0022_costs_and_margin.sql`)
204222
205223 `cost_lines`, `cost_map`, `revenue_map`, `own_counts`,
+53−2
127127 runCapMicros?: number | null;
128128 /** What one issue's agents may spend in all, in place of the owners' and the default $10; null for none. */
129129 issueCapMicros?: number | null;
130+ /** Days of audit log its workspaces keep, in place of the plan's (7 free, 90 on the plan), longer or shorter; null for the plan's. */
131+ auditRetentionDays?: number | null;
130132 /** A hold on new compute, with why; null for none. */
131133 hold?: string | null;
132134 };
209211 /** The plan's included usage each month, and what of it is used. */
210212 includedMicros?: number;
211213 includedUsedMicros?: number;
212− /** How far back the audit log can be read and exported: the same on every plan. */
214+ /** How far back the audit log can be read and exported, and what is kept: the plan's days, or what g1t staff set for the account. */
213215 auditRetentionDays: number;
216+ /** Whether `auditRetentionDays` is what staff set for the account rather than the plan's. */
217+ auditRetentionCustom?: boolean;
214218 /** Private repository storage free for every workspace: past it, the plan pays and a free workspace's pushes stop. */
215219 freePrivateStorageBytes: number;
216220 /** The last daily measure of the workspace's private repositories (a lower bound). */
680684 ossRepoMicros: number;
681685 /** Private repository storage free for every workspace. Past it, the plan pays; a free workspace's pushes stop. */
682686 freePrivateStorageBytes: number;
683− /** Days of audit log, the same on every plan. */
687+ /** Days of audit log a free workspace keeps. */
684688 auditRetentionDays: number;
689+ /** Days of audit log the g1t plan keeps, and g1t's own and enterprise workspaces; longer by arrangement. */
690+ planAuditRetentionDays?: number;
685691 /** The smallest amount a card is charged when a month closes; less carries over. */
686692 minChargeMicros: number;
687693 /** Git operations free for every workspace each month. Past it, the plan pays; a free workspace is slowed down. */
783789 /** What the workspace's agents cost since `since`, broken down. Members only. */
784790 usage(workspace: string, viewer: Viewer, since: string): Promise<Result<Usage>>;
785791 /**
792+ * The model tokens the workspace's runs used, day by day over the last
793+ * `days` (42, at most 366), for everyone or for one `person`. Members
794+ * only; a member may ask only for themselves, an owner for anyone.
795+ */
796+ tokenUsage(workspace: string, viewer: User, options?: { person?: string; days?: number }): Promise<Result<TokenUsage>>;
797+ /**
798+ * What one model answer used, added to its run's count for the day. The
799+ * model proxy sends it; for usage views only, as runs are priced from AI
800+ * Gateway. False when there was nothing to count.
801+ */
802+ recordTokens(usage: {
803+ workspace: string;
804+ /** The model session's id, one per run. */
805+ session: string;
806+ /** The person the run is for, by username. */
807+ person?: string | null;
808+ model: string;
809+ tier?: "small" | "large" | null;
810+ input: number;
811+ output: number;
812+ cacheRead: number;
813+ cacheWrite: number;
814+ }): Promise<Result<boolean>>;
815+ /**
786816 * Prepays usage ($25 at least) and returns the page to send the person to:
787817 * by card with 3-D Secure, or by bank transfer from $1,000. Owners only.
788818 * The payment's id comes back to `returnUrl` as `session`.
953983 /** One slice of usage: what it was for, what it cost, how many runs. */
954984 export type UsageSlice = { key: string; micros: number; runs: number };
955985
986+/** The model tokens runs used over a window of days. */
987+export type TokenUsage = {
988+ /** `YYYY-MM-DD`, the first day counted. */
989+ since: string;
990+ /** The window's length: 42 unless asked, 366 at most. */
991+ days: number;
992+ /** Null for the whole workspace. */
993+ person: string | null;
994+ totalTokens: number;
995+ inputTokens: number;
996+ outputTokens: number;
997+ cacheReadTokens: number;
998+ cacheWriteTokens: number;
999+ /** What those runs were charged, as `usage` measures it. */
1000+ costMicros: number;
1001+ /** Days in the window with any tokens. */
1002+ activeDays: number;
1003+ /** Every day in the window, oldest first, zeros included. */
1004+ byDay: { day: string; tokens: number }[];
1005+};
1006+
9561007 /** What a workspace's agents cost over a period. */
9571008 export type Usage = {
9581009 since: string;
+3−0
373373 before: filter.before ?? null,
374374 }),
375375 usage: (workspace, viewer, since) => call("usage", { workspace, viewer, since }),
376+ tokenUsage: (workspace, viewer, options = {}) =>
377+ call("token_usage", { workspace, viewer, person: options.person ?? null, days: options.days ?? null }),
378+ recordTokens: (usage) => call("record_tokens", usage),
376379 checkout: (actor, workspace, amountCents, returnUrl, method = "card") =>
377380 call("checkout", { actor, workspace, amountCents, returnUrl, method }),
378381 confirm: (workspace, viewer, session) => call("confirm", { workspace, viewer, session }),
+4−0
144144 session: string;
145145 /** For `g1t`: the tier the run was routed to. */
146146 tier?: "small" | "large" | null;
147+ /** The person the run is for, by username. Null when nobody asked; never `g1t`. */
148+ requestedBy: string | null;
147149 baseUrl: string | null;
148150 apiKey: string | null;
149151 authHeader: string | null;
187189 hostedOpen: boolean;
188190 /** The tier the run is routed to on g1t's hosted models, for the gateway's logs. */
189191 tier?: "small" | "large" | null;
192+ /** The person the run is for, by username, so its tokens show under them. */
193+ requestedBy?: string | null;
190194 }): Promise<Result<ModelSession>>;
191195 routes(workspace: string, viewer: Viewer): Promise<Result<ModelRoute[]>>;
192196 setRoutes(actor: User, workspace: string, routes: ModelRoute[]): Promise<Result<ModelRoute[]>>;
+51−2
4040 pub updated_at: String,
4141 }
4242
43+/// What a workflow's row says once its file has left the default branch.
44+pub const GONE: &str = "Its file is no longer on the default branch.";
45+
46+/// Whether a synced workflow could start on `event`: it lists the event,
47+/// or it did not parse (and is still on the branch), so reading it again
48+/// reports why.
49+pub fn could_start(events: &str, error: Option<&str>, event: &str) -> bool {
50+ match error {
51+ Some(error) => error != GONE,
52+ None => serde_json::from_str::<Vec<String>>(events).is_ok_and(|events| events.iter().any(|e| e == event)),
53+ }
54+}
55+
4356 impl Actions {
4457 /// The workflow files of `path` as of `git_ref` (the default branch
4558 /// when absent).
146159 for row in existing.iter().filter(|row| !kept.contains(&row.path)) {
147160 statements.push(
148161 self.db
149− .prepare("UPDATE workflows SET crons = '[]', error = 'Its file is no longer on the default branch.' WHERE id = ?")
150− .bind(&[row.id.as_str().into()])?,
162+ .prepare("UPDATE workflows SET crons = '[]', error = ? WHERE id = ?")
163+ .bind(&[GONE.into(), row.id.as_str().into()])?,
151164 );
152165 }
153166 statements.push(
159172 Ok(())
160173 }
161174
175+ /// Whether any of the repository's default-branch workflows could
176+ /// start on `event`, from the synced table; None before the first sync.
177+ pub async fn listens(&self, repo_id: &str, event: &str) -> Result<Option<bool>> {
178+ #[derive(Deserialize)]
179+ struct Row {
180+ events: String,
181+ error: Option<String>,
182+ }
183+ if !self.synced(repo_id).await? {
184+ return Ok(None);
185+ }
186+ let rows = self
187+ .db
188+ .prepare("SELECT events, error FROM workflows WHERE repo_id = ? AND state = 'active'")
189+ .bind(&[repo_id.into()])?
190+ .all()
191+ .await?
192+ .results::<Row>()?;
193+ Ok(Some(rows.iter().any(|row| could_start(&row.events, row.error.as_deref(), event))))
194+ }
195+
162196 pub async fn synced(&self, repo_id: &str) -> Result<bool> {
163197 Ok(self
164198 .db
198232 .ok_or_else(|| worker::Error::RustError("the workflow was not recorded".into()))
199233 }
200234 }
235+
236+#[cfg(test)]
237+mod tests {
238+ use super::*;
239+
240+ #[test]
241+ fn a_synced_workflow_starts_on_the_events_it_lists_or_when_broken() {
242+ assert!(could_start(r#"["push","issues"]"#, None, "issues"));
243+ assert!(!could_start(r#"["push","pull_request"]"#, None, "issue_comment"));
244+ // A file that does not parse is read again, so its error shows.
245+ assert!(could_start("[]", Some("bad yaml"), "issues"));
246+ // A file gone from the branch starts nothing.
247+ assert!(!could_start("[]", Some(GONE), "issues"));
248+ }
249+}
+6−0
347347 }
348348 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
349349 for (event_name, action) in mapped {
350+ // Issues and comments start the default branch's workflows,
351+ // which the synced table lists: when none listens, nothing is
352+ // read from git. Agents make many of these events.
353+ if matches!(event_name, "issues" | "issue_comment") && self.listens(repo_id, event_name).await? == Some(false) {
354+ continue;
355+ }
350356 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
351357 continue;
352358 };
+3−0
1+-- Days of audit log an account's workspaces keep, set by g1t staff in
2+-- sudo in place of the plan's (7 free, 90 on the plan). NULL: the plan's.
3+ALTER TABLE billing_accounts ADD COLUMN audit_retention_days INTEGER;
+23−0
1+-- Model tokens, added up per day and run, for usage views: the model proxy
2+-- reports what each answer used. Billing still prices runs from AI
3+-- Gateway, never from these.
4+CREATE TABLE token_usage (
5+ -- YYYY-MM-DD, UTC.
6+ day TEXT NOT NULL,
7+ workspace TEXT NOT NULL,
8+ -- The person the run was for, by username; empty when nobody asked.
9+ person TEXT NOT NULL DEFAULT '',
10+ -- The model session's id, one per run (runs.session_id).
11+ session TEXT NOT NULL,
12+ model TEXT NOT NULL,
13+ -- On g1t's hosted models: small or large.
14+ tier TEXT,
15+ input INTEGER NOT NULL DEFAULT 0,
16+ output INTEGER NOT NULL DEFAULT 0,
17+ cache_read INTEGER NOT NULL DEFAULT 0,
18+ cache_write INTEGER NOT NULL DEFAULT 0,
19+ requests INTEGER NOT NULL DEFAULT 0,
20+ PRIMARY KEY (day, workspace, person, session, model)
21+);
22+CREATE INDEX token_usage_by_workspace_day ON token_usage (workspace, day);
23+CREATE INDEX token_usage_by_person_day ON token_usage (workspace, person, day);
+17−4
5656 #[serde(default)]
5757 issue_cap_micros: Option<i64>,
5858 #[serde(default)]
59+ audit_retention_days: Option<u32>,
60+ #[serde(default)]
5961 hold: Option<String>,
6062 }
6163
6971 max_concurrent_agents: self.max_concurrent_agents,
7072 run_cap_micros: self.run_cap_micros,
7173 issue_cap_micros: self.issue_cap_micros,
74+ audit_retention_days: self.audit_retention_days,
7275 hold: self.hold.clone().filter(|h| !h.trim().is_empty()),
7376 }
7477 }
505508 if a.allowances.max_concurrent_agents.is_some_and(|n| n == 0 || n > 1_000) {
506509 return Ok(Outcome::fail(FailureCode::Invalid, "Agents at once is between 1 and 1,000."));
507510 }
511+ if let Some(why) = crate::retention::invalid_days(&self.plans, a.allowances.audit_retention_days) {
512+ return Ok(Outcome::fail(FailureCode::Invalid, why));
513+ }
508514 let Some(account) = self.find_account(&a.id).await? else {
509515 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
510516 };
514520 self.db
515521 .prepare(
516522 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at,
517− team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, run_cap_micros, hold, issue_cap_micros)
518− VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
523+ team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, run_cap_micros, hold, issue_cap_micros,
524+ audit_retention_days)
525+ VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
519526 ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8,
520− max_concurrent_agents = ?9, run_cap_micros = ?10, hold = ?11, issue_cap_micros = ?12",
527+ max_concurrent_agents = ?9, run_cap_micros = ?10, hold = ?11, issue_cap_micros = ?12,
528+ audit_retention_days = ?13",
521529 )
522530 .bind(&[
523531 account.id.as_str().into(),
532540 opt(a.allowances.run_cap_micros),
533541 optional(a.allowances.hold.as_deref().map(str::trim).filter(|h| !h.is_empty())),
534542 opt(a.allowances.issue_cap_micros),
543+ a.allowances.audit_retention_days.map_or(JsValue::NULL, JsValue::from),
535544 ])?
536545 .run()
537546 .await?;
713722 if let Some(m) = a.issue_cap_micros {
714723 parts.push(format!("issue cap {}", crate::features::dollars(m)));
715724 }
725+ if let Some(days) = a.audit_retention_days {
726+ parts.push(format!("audit log {days} days"));
727+ }
716728 if let Some(hold) = &a.hold {
717729 parts.push(format!("hold: {hold}"));
718730 }
764776 max_concurrent_agents: Some(4),
765777 run_cap_micros: Some(3_000_000),
766778 issue_cap_micros: None,
779+ audit_retention_days: Some(365),
767780 hold: Some("mining".into()),
768781 };
769782 assert_eq!(
770783 describe_allowances(&given),
771− "plan given, open-source share $5.00 a repository, trial $2.00, 4 agents at once, run cap $3.00, hold: mining"
784+ "plan given, open-source share $5.00 a repository, trial $2.00, 4 agents at once, run cap $3.00, audit log 365 days, hold: mining"
772785 );
773786 }
774787
+2−1
506506 prepaid_micros: limit.prepaid_micros,
507507 included_micros: if has_plan { self.plans.plan_included_micros } else { 0 },
508508 included_used_micros: included_used,
509− audit_retention_days: self.plans.audit_days,
509+ audit_retention_days: crate::retention::effective_days(&self.plans, plan, account.allowances.audit_retention_days),
510+ audit_retention_custom: account.allowances.audit_retention_days.is_some(),
510511 free_private_storage_bytes: self.plans.free_storage_bytes,
511512 private_storage_bytes: stored,
512513 oss_paid_micros: oss,
+13−2
5959 /// free for every workspace. Past it, the plan pays at cost plus the
6060 /// margin; a free workspace's pushes to private repositories stop.
6161 pub free_storage_bytes: i64,
62− /// `AUDIT_RETENTION_DAYS`: the same on every plan.
62+ /// `FREE_AUDIT_RETENTION_DAYS`: days of audit log a free workspace
63+ /// keeps. `AUDIT_RETENTION_DAYS`: the plan's, g1t's own and an
64+ /// enterprise's. `AUDIT_MAX_DAYS`: the most staff can set for an
65+ /// account; the events service deletes everything older regardless.
66+ pub free_audit_days: u32,
6367 pub audit_days: u32,
68+ pub audit_max_days: u32,
6469 /// `RUN_CAP_MICROS` and `ISSUE_CAP_MICROS`: one run's spend cap, and
6570 /// agents' spend on one issue in all.
6671 pub run_cap_micros: i64,
9398 trial_monthly_pool_micros: 100_000_000,
9499 min_charge_micros: 5_000_000,
95100 free_storage_bytes: 1_000_000_000,
101+ free_audit_days: 7,
96102 audit_days: 90,
103+ audit_max_days: 400,
97104 run_cap_micros: g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS,
98105 issue_cap_micros: 10_000_000,
99106 paid_start_micros: 100_000_000,
120127 trial_monthly_pool_micros: number("TRIAL_MONTHLY_POOL_MICROS", d.trial_monthly_pool_micros),
121128 min_charge_micros: number("MIN_CHARGE_MICROS", d.min_charge_micros),
122129 free_storage_bytes: number("FREE_PRIVATE_STORAGE_BYTES", d.free_storage_bytes),
123− audit_days: number("AUDIT_RETENTION_DAYS", d.audit_days.into()) as u32,
130+ free_audit_days: number("FREE_AUDIT_RETENTION_DAYS", d.free_audit_days.into()).max(1) as u32,
131+ audit_days: number("AUDIT_RETENTION_DAYS", d.audit_days.into()).max(1) as u32,
132+ audit_max_days: number("AUDIT_MAX_DAYS", d.audit_max_days.into()).max(1) as u32,
124133 run_cap_micros: number("RUN_CAP_MICROS", d.run_cap_micros),
125134 issue_cap_micros: number("ISSUE_CAP_MICROS", d.issue_cap_micros),
126135 paid_start_micros: number("LIMIT_PAID_START_MICROS", d.paid_start_micros),
760769 assert_eq!(c.trial_monthly_pool_micros, 100_000_000);
761770 assert_eq!(c.min_charge_micros, 5_000_000);
762771 assert_eq!(c.free_storage_bytes, 1_000_000_000);
772+ assert_eq!(c.free_audit_days, 7);
763773 assert_eq!(c.audit_days, 90);
774+ assert_eq!(c.audit_max_days, 400);
764775 assert_eq!(c.run_cap_micros, g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS);
765776 assert_eq!(c.issue_cap_micros, 10_000_000);
766777 assert_eq!(c.paid_start_micros, 100_000_000);
+2−1
420420 oss_pool_micros: self.plans.oss_pool_micros,
421421 oss_repo_micros: self.plans.oss_repo_micros,
422422 free_private_storage_bytes: self.plans.free_storage_bytes,
423− audit_retention_days: self.plans.audit_days,
423+ audit_retention_days: self.plans.free_audit_days,
424+ plan_audit_retention_days: self.plans.audit_days,
424425 min_charge_micros: self.plans.min_charge_micros,
425426 git_operations_included: self.plans.git_included,
426427 paid_start_ceiling_micros: self.plans.paid_start_micros,
+5−0
3737 mod keeper;
3838 mod limits;
3939 mod rename;
40+mod retention;
4041 mod stripe;
4142 mod stripe_sync;
43+mod tokens;
4244
4345 use g1t_contracts::billing::*;
4446 use g1t_contracts::time::rfc3339;
11051107 "account" => reply(&billing.account(args(body)?).await?),
11061108 "ledger" => reply(&billing.ledger(args(body)?).await?),
11071109 "usage" => reply(&billing.usage(args(body)?).await?),
1110+ "record_tokens" => reply(&billing.record_tokens(args(body)?).await?),
1111+ "token_usage" => reply(&billing.token_usage(args(body)?).await?),
11081112 "checkout" => reply(&billing.checkout(args(body)?).await?),
11091113 "confirm" => reply(&billing.confirm(args(body)?).await?),
11101114 "can_start" => reply(&billing.can_start(args(body)?).await?),
11531157 "admin_credit" => reply(&billing.admin_credit(args(body)?).await?),
11541158 "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?),
11551159 "entitlements" => reply(&billing.entitlements(args(body)?).await?),
1160+ "audit_retention" => reply(&billing.audit_retention(args(body)?).await?),
11561161 "reserve" => reply(&billing.reserve(args(body)?).await?),
11571162 "settle" => reply(&billing.settle_reservation(args(body)?).await?),
11581163 "card_check" => reply(&billing.card_check(args(body)?).await?),
+81−0
1+//! How long each workspace's audit log is kept.
2+//!
3+//! A free workspace keeps `FREE_AUDIT_RETENTION_DAYS` (7); the g1t plan,
4+//! g1t's own workspaces and an enterprise's keep `AUDIT_RETENTION_DAYS`
5+//! (90). Staff can set an account's own number in sudo, such as for an
6+//! organization that pays for longer, up to `AUDIT_MAX_DAYS` (400). What
7+//! staff set wins over the plan's either way. The events service asks for
8+//! these once a day (`audit_retention`) and deletes what is older.
9+
10+use futures_util::future::try_join_all;
11+use g1t_contracts::billing::{AuditRetention, AuditRetentionArgs, PlanKind};
12+use worker::Result;
13+
14+use crate::Billing;
15+use crate::credits::Config;
16+
17+/// Days of audit log a workspace keeps: what staff set for its account,
18+/// or else its plan's.
19+pub(crate) fn effective_days(plans: &Config, plan: PlanKind, custom: Option<u32>) -> u32 {
20+ custom.unwrap_or(match plan {
21+ PlanKind::Free => plans.free_audit_days,
22+ PlanKind::Paid | PlanKind::Internal | PlanKind::Enterprise => plans.audit_days,
23+ })
24+}
25+
26+/// Why a number staff typed cannot be an account's retention, or None
27+/// when it can.
28+pub(crate) fn invalid_days(plans: &Config, days: Option<u32>) -> Option<String> {
29+ days.filter(|d| !(1..=plans.audit_max_days).contains(d))
30+ .map(|_| format!("Audit log days is between 1 and {}, or empty for the plan's.", plans.audit_max_days))
31+}
32+
33+impl Billing {
34+ /// `audit_retention`: each workspace's days, its account and plan read
35+ /// once and all of them at the same time.
36+ pub(crate) async fn audit_retention(&self, a: AuditRetentionArgs) -> Result<Vec<AuditRetention>> {
37+ try_join_all(a.workspaces.iter().map(|workspace| async move {
38+ let workspace = workspace.to_lowercase();
39+ let account = self.account_of(&workspace).await?;
40+ let plan = self.plan_kind_for(&workspace, &account).await?;
41+ let days = effective_days(&self.plans, plan, account.allowances.audit_retention_days);
42+ Ok::<_, worker::Error>(AuditRetention { workspace, days })
43+ }))
44+ .await
45+ }
46+}
47+
48+#[cfg(test)]
49+mod tests {
50+ use super::*;
51+
52+ #[test]
53+ fn free_keeps_a_week_and_the_plan_ninety_days() {
54+ let plans = Config::default();
55+ assert_eq!(effective_days(&plans, PlanKind::Free, None), 7);
56+ assert_eq!(effective_days(&plans, PlanKind::Paid, None), 90);
57+ assert_eq!(effective_days(&plans, PlanKind::Internal, None), 90);
58+ assert_eq!(effective_days(&plans, PlanKind::Enterprise, None), 90);
59+ }
60+
61+ #[test]
62+ fn what_staff_set_wins_either_way() {
63+ let plans = Config::default();
64+ assert_eq!(effective_days(&plans, PlanKind::Free, Some(30)), 30);
65+ assert_eq!(effective_days(&plans, PlanKind::Paid, Some(365)), 365);
66+ assert_eq!(effective_days(&plans, PlanKind::Enterprise, Some(14)), 14);
67+ }
68+
69+ #[test]
70+ fn staff_set_between_one_day_and_the_most() {
71+ let plans = Config::default();
72+ assert_eq!(invalid_days(&plans, None), None);
73+ assert_eq!(invalid_days(&plans, Some(1)), None);
74+ assert_eq!(invalid_days(&plans, Some(400)), None);
75+ assert_eq!(
76+ invalid_days(&plans, Some(0)).as_deref(),
77+ Some("Audit log days is between 1 and 400, or empty for the plan's.")
78+ );
79+ assert!(invalid_days(&plans, Some(401)).is_some());
80+ }
81+}
+300−0
1+//! Model tokens, counted per run for usage views.
2+//!
3+//! The model proxy reports what each answer used (`record_tokens`), and the
4+//! day's row for that run adds it up: one row per day, workspace, person,
5+//! session and model. `token_usage` reads a window of those back, for the
6+//! whole workspace or for one person, with what the window's runs were
7+//! charged. Billing still prices runs from AI Gateway, never from these.
8+
9+use futures_util::future::try_join;
10+use g1t_contracts::billing::{DayTokens, RecordTokensArgs, TokenUsage, TokenUsageArgs};
11+use g1t_contracts::identity::AGENT_NAME;
12+use g1t_contracts::time::rfc3339;
13+use g1t_contracts::{FailureCode, Outcome, Role};
14+use g1t_kit::now_ms;
15+use serde::Deserialize;
16+use worker::wasm_bindgen::JsValue;
17+use worker::Result;
18+
19+use crate::{Billing, members_only};
20+
21+/// The window `token_usage` shows when asked for none, and the longest.
22+pub(crate) const DEFAULT_DAYS: u32 = 42;
23+pub(crate) const MAX_DAYS: u32 = 366;
24+const DAY_MS: u64 = 86_400_000;
25+
26+/// One answer's tokens, ready to add to its day's row.
27+#[derive(Debug, PartialEq)]
28+pub(crate) struct TokenRow {
29+ pub day: String,
30+ pub workspace: String,
31+ pub person: String,
32+ pub session: String,
33+ pub model: String,
34+ pub tier: Option<String>,
35+ pub counts: [u64; 4],
36+}
37+
38+/// The UTC day of a time, `YYYY-MM-DD`.
39+fn day_of(ms: u64) -> String {
40+ rfc3339(ms)[..10].to_owned()
41+}
42+
43+/// Who a run's tokens count for: a username, lowercased, or empty for
44+/// nobody. The agent is not a person.
45+pub(crate) fn person_of(username: Option<&str>) -> String {
46+ let name = username.unwrap_or_default().trim().to_lowercase();
47+ if name == AGENT_NAME { String::new() } else { name }
48+}
49+
50+/// What to add for one report, or None when there is nothing to count or
51+/// nothing to count it under.
52+pub(crate) fn token_row(a: &RecordTokensArgs, now: u64) -> Option<TokenRow> {
53+ let counts = [a.input, a.output, a.cache_read, a.cache_write];
54+ let workspace = a.workspace.trim().to_lowercase();
55+ let session = a.session.trim();
56+ if counts.iter().all(|n| *n == 0) || workspace.is_empty() || session.is_empty() {
57+ return None;
58+ }
59+ let model = a.model.trim();
60+ Some(TokenRow {
61+ day: day_of(now),
62+ workspace,
63+ person: person_of(a.person.as_deref()),
64+ session: session.chars().take(64).collect(),
65+ model: if model.is_empty() { "unknown".to_owned() } else { model.chars().take(200).collect() },
66+ tier: a.tier.as_deref().filter(|tier| matches!(*tier, "small" | "large")).map(str::to_owned),
67+ counts,
68+ })
69+}
70+
71+/// The days of a window that ends today, oldest first, and how many.
72+pub(crate) fn window(now: u64, days: Option<u32>) -> Vec<String> {
73+ let days = days.unwrap_or(DEFAULT_DAYS).clamp(1, MAX_DAYS);
74+ (0..u64::from(days)).rev().map(|back| day_of(now.saturating_sub(back * DAY_MS))).collect()
75+}
76+
77+/// Every day of the window with its tokens, zeros included.
78+pub(crate) fn fill(days: &[String], counted: &[(String, u64)]) -> Vec<DayTokens> {
79+ days.iter()
80+ .map(|day| DayTokens {
81+ day: day.clone(),
82+ tokens: counted.iter().filter(|(d, _)| d == day).map(|(_, n)| n).sum(),
83+ })
84+ .collect()
85+}
86+
87+/// D1 takes numbers as doubles; a count of tokens fits exactly.
88+fn number(n: u64) -> JsValue {
89+ JsValue::from_f64(n as f64)
90+}
91+
92+impl Billing {
93+ pub(crate) async fn record_tokens(&self, a: RecordTokensArgs) -> Result<Outcome<bool>> {
94+ let Some(row) = token_row(&a, now_ms()) else {
95+ return Ok(Outcome::Ok(false));
96+ };
97+ let [input, output, cache_read, cache_write] = row.counts;
98+ self.db
99+ .prepare(
100+ "INSERT INTO token_usage (day, workspace, person, session, model, tier, input, output, cache_read, cache_write, requests)
101+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
102+ ON CONFLICT (day, workspace, person, session, model) DO UPDATE SET
103+ input = input + excluded.input,
104+ output = output + excluded.output,
105+ cache_read = cache_read + excluded.cache_read,
106+ cache_write = cache_write + excluded.cache_write,
107+ requests = requests + 1,
108+ tier = COALESCE(excluded.tier, tier)",
109+ )
110+ .bind(&[
111+ row.day.into(),
112+ row.workspace.into(),
113+ row.person.into(),
114+ row.session.into(),
115+ row.model.into(),
116+ row.tier.map_or(JsValue::NULL, JsValue::from),
117+ number(input),
118+ number(output),
119+ number(cache_read),
120+ number(cache_write),
121+ ])?
122+ .run()
123+ .await?;
124+ Ok(Outcome::Ok(true))
125+ }
126+
127+ pub(crate) async fn token_usage(&self, a: TokenUsageArgs) -> Result<Outcome<TokenUsage>> {
128+ let workspace = a.workspace.to_lowercase();
129+ let Some(viewer) = a.viewer.filter(|viewer| viewer.is_member(&workspace)) else {
130+ return Ok(members_only());
131+ };
132+ let person = a.person.as_deref().map(|name| person_of(Some(name))).filter(|name| !name.is_empty());
133+ if let Some(person) = &person
134+ && *person != viewer.username.to_lowercase()
135+ && viewer.role_in(&workspace) != Some(Role::Owner)
136+ {
137+ return Ok(Outcome::fail(
138+ FailureCode::Forbidden,
139+ "Only owners can see another person's usage.",
140+ ));
141+ }
142+ let days = window(now_ms(), a.days);
143+ let since = days[0].clone();
144+ let mut binds: Vec<JsValue> = vec![workspace.as_str().into(), since.as_str().into()];
145+ if let Some(person) = &person {
146+ binds.push(person.as_str().into());
147+ }
148+ let for_person = if person.is_some() { " AND person = ?3" } else { "" };
149+
150+ #[derive(Deserialize)]
151+ struct DayRow {
152+ day: String,
153+ input: Option<f64>,
154+ output: Option<f64>,
155+ cache_read: Option<f64>,
156+ cache_write: Option<f64>,
157+ }
158+ #[derive(Deserialize)]
159+ struct Charged {
160+ micros: Option<f64>,
161+ }
162+ let by_day = async {
163+ self.db
164+ .prepare(format!(
165+ "SELECT day, SUM(input) AS input, SUM(output) AS output, SUM(cache_read) AS cache_read, SUM(cache_write) AS cache_write
166+ FROM token_usage WHERE workspace = ?1 AND day >= ?2{for_person} GROUP BY day"
167+ ))
168+ .bind(&binds)?
169+ .all()
170+ .await?
171+ .results::<DayRow>()
172+ };
173+ // What the window's runs cost, whoever paid: at g1t's price, what was
174+ // left to pay plus what included usage, credit, the trial, the
175+ // open-source pool or a comp covered; a run charged nothing at all
176+ // (comped, or while g1t charges nothing) at the provider's cost.
177+ // For one person, the runs whose sessions counted their tokens.
178+ let measure = if self.free {
179+ "COALESCE(l.cost_micros, 0)"
180+ } else {
181+ "CASE WHEN (-l.amount_micros + l.credit_micros + l.trial_micros + l.oss_micros + l.given_micros) > 0
182+ THEN (-l.amount_micros + l.credit_micros + l.trial_micros + l.oss_micros + l.given_micros)
183+ ELSE COALESCE(l.cost_micros, 0) END"
184+ };
185+ let sessions = if person.is_some() {
186+ " AND r.session_id IN (SELECT session FROM token_usage WHERE workspace = ?1 AND person = ?3 AND day >= ?2)"
187+ } else {
188+ ""
189+ };
190+ let charged = async {
191+ self.db
192+ .prepare(format!(
193+ "SELECT SUM({measure}) AS micros FROM ledger l JOIN runs r ON r.id = l.reference
194+ WHERE l.workspace = ?1 AND l.kind = 'usage' AND l.created_at >= ?2{sessions}"
195+ ))
196+ .bind(&binds)?
197+ .first::<Charged>(None)
198+ .await
199+ };
200+ // Both read independently, so they go to D1 at once.
201+ let (rows, charged) = try_join(by_day, charged).await?;
202+
203+ let count = |n: Option<f64>| n.unwrap_or_default().max(0.0) as u64;
204+ let mut totals = [0u64; 4];
205+ let mut counted = Vec::with_capacity(rows.len());
206+ for row in &rows {
207+ let row_counts = [count(row.input), count(row.output), count(row.cache_read), count(row.cache_write)];
208+ for (total, n) in totals.iter_mut().zip(row_counts) {
209+ *total += n;
210+ }
211+ counted.push((row.day.clone(), row_counts.iter().sum::<u64>()));
212+ }
213+ let by_day = fill(&days, &counted);
214+ Ok(Outcome::Ok(TokenUsage {
215+ since,
216+ days: days.len() as u32,
217+ person,
218+ total_tokens: totals.iter().sum(),
219+ input_tokens: totals[0],
220+ output_tokens: totals[1],
221+ cache_read_tokens: totals[2],
222+ cache_write_tokens: totals[3],
223+ cost_micros: charged.and_then(|c| c.micros).unwrap_or_default().round() as i64,
224+ active_days: by_day.iter().filter(|day| day.tokens > 0).count() as u32,
225+ by_day,
226+ }))
227+ }
228+}
229+
230+#[cfg(test)]
231+mod tests {
232+ use super::*;
233+
234+ // 2026-10-06T12:00:00Z.
235+ const NOW: u64 = 1_791_288_000_000;
236+
237+ fn args() -> RecordTokensArgs {
238+ RecordTokensArgs {
239+ workspace: " Acme ".into(),
240+ session: "ms_abc".into(),
241+ person: Some("Ada".into()),
242+ model: "claude-opus".into(),
243+ tier: Some("large".into()),
244+ input: 10,
245+ output: 5,
246+ cache_read: 0,
247+ cache_write: 0,
248+ }
249+ }
250+
251+ #[test]
252+ fn a_report_is_added_to_today_under_its_person() {
253+ let row = token_row(&args(), NOW).unwrap();
254+ assert_eq!(row.day, "2026-10-06");
255+ assert_eq!(row.workspace, "acme");
256+ assert_eq!(row.person, "ada");
257+ assert_eq!(row.tier.as_deref(), Some("large"));
258+ assert_eq!(row.counts, [10, 5, 0, 0]);
259+ }
260+
261+ #[test]
262+ fn nothing_used_or_nowhere_to_put_it_is_not_counted() {
263+ assert!(token_row(&RecordTokensArgs { input: 0, output: 0, ..args() }, NOW).is_none());
264+ assert!(token_row(&RecordTokensArgs { session: " ".into(), ..args() }, NOW).is_none());
265+ assert!(token_row(&RecordTokensArgs { workspace: String::new(), ..args() }, NOW).is_none());
266+ }
267+
268+ #[test]
269+ fn the_agent_is_nobody_and_odd_tiers_and_models_are_tidied() {
270+ let row = token_row(
271+ &RecordTokensArgs { person: Some("g1t".into()), tier: Some("huge".into()), model: " ".into(), ..args() },
272+ NOW,
273+ )
274+ .unwrap();
275+ assert_eq!(row.person, "");
276+ assert_eq!(row.tier, None);
277+ assert_eq!(row.model, "unknown");
278+ assert_eq!(person_of(None), "");
279+ }
280+
281+ #[test]
282+ fn the_window_ends_today_oldest_first_and_is_bounded() {
283+ let days = window(NOW, Some(3));
284+ assert_eq!(days, vec!["2026-10-04", "2026-10-05", "2026-10-06"]);
285+ assert_eq!(window(NOW, None).len(), DEFAULT_DAYS as usize);
286+ assert_eq!(window(NOW, Some(0)).len(), 1);
287+ assert_eq!(window(NOW, Some(5000)).len(), MAX_DAYS as usize);
288+ // Across a month's end.
289+ assert_eq!(window(NOW, Some(7))[0], "2026-09-30");
290+ }
291+
292+ #[test]
293+ fn every_day_is_filled_with_zeros_where_nothing_ran() {
294+ let days = window(NOW, Some(3));
295+ let filled = fill(&days, &[("2026-10-05".into(), 40), ("2026-09-01".into(), 9)]);
296+ let tokens: Vec<u64> = filled.iter().map(|d| d.tokens).collect();
297+ assert_eq!(tokens, vec![0, 40, 0]);
298+ assert_eq!(filled[2].day, "2026-10-06");
299+ }
300+}
+7−1
5454 "PLAN_INCLUDED_MICROS": "10000000",
5555 // 1 GB of private storage free for every workspace: past it, the plan
5656 // pays at cost plus the margin, and a free workspace's pushes to
57− // private repositories stop. The audit log is kept 90 days on every plan.
57+ // private repositories stop.
5858 "FREE_PRIVATE_STORAGE_BYTES": "1000000000",
59+ // The audit log (src/retention.rs): 7 days for a free workspace, 90 on
60+ // the plan, for g1t's own and for an enterprise. Staff can set an
61+ // account's own days in sudo, up to AUDIT_MAX_DAYS; keep that at most
62+ // the events service's AUDIT_MAX_DAYS, which deletes anything older.
63+ "FREE_AUDIT_RETENTION_DAYS": "7",
5964 "AUDIT_RETENTION_DAYS": "90",
65+ "AUDIT_MAX_DAYS": "400",
6066 // The trial (src/credits.rs, src/cards.rs): $5 of usage once per new
6167 // workspace, granted after a card check (one per card), out of a pool
6268 // for everyone ($100) that resets each calendar month (UTC). Either at
+15−0
1+-- Audit entries are kept by plan (src/audit.rs): 7 days for a free
2+-- workspace, 90 on the plan, or what g1t staff set for its account, and
3+-- never past AUDIT_MAX_DAYS. The daily purge deletes everything older than
4+-- the ceiling by time, finds the workspaces with entries older than the
5+-- shortest retention, and deletes each one's older than its own days; these
6+-- indexes keep each of those a seek rather than a scan.
7+CREATE INDEX IF NOT EXISTS audit_workspace_time ON audit_entries (workspace, time);
8+CREATE INDEX IF NOT EXISTS audit_time ON audit_entries (time);
9+
10+-- Where the last daily run stopped, so the next carries on from there: one
11+-- run looks at a bounded number of workspaces. Empty: from the start.
12+CREATE TABLE IF NOT EXISTS audit_purge_cursor (
13+ id INTEGER PRIMARY KEY CHECK (id = 1),
14+ after TEXT NOT NULL DEFAULT ''
15+);
+183−5
88 AuditEntry, AuditPage, AuditVisibility, ListAuditArgs, MAX_AUDIT_PAGE, NewAuditEntry,
99 RecordAuditArgs,
1010 };
11+use g1t_contracts::billing::{AuditRetention, AuditRetentionArgs};
1112 use g1t_contracts::events::{Event, WorkspaceRenamed};
1213 use g1t_contracts::new_id;
1314 use g1t_contracts::time::rfc3339;
1415 use g1t_kit::now_ms;
1516 use serde::Deserialize;
1617 use worker::wasm_bindgen::JsValue;
17−use worker::{D1Database, Result};
18+use worker::{D1Database, Fetcher, Result};
1819
1920 const DEFAULT_PAGE: u32 = 100;
2021 /// More than one request ever records.
277278 Ok(AuditPage { entries, next })
278279 }
279280
280−/// Entries are kept this many days unless `AUDIT_KEEP_DAYS` says otherwise:
281−/// what the audit log reads back, the same on every plan (90 days).
282−pub const DEFAULT_KEEP_DAYS: u32 = 90;
281+/// No entry is kept longer than this, whatever its workspace's plan, unless
282+/// `AUDIT_MAX_DAYS` says otherwise. Keep it at least billing's
283+/// `AUDIT_MAX_DAYS`, the most staff can set for an account.
284+pub const DEFAULT_MAX_DAYS: u32 = 400;
285+/// The shortest any workspace keeps (`AUDIT_MIN_DAYS`, a free workspace's
286+/// 7 days): only workspaces with entries older than this are asked about.
287+pub const DEFAULT_MIN_DAYS: u32 = 7;
288+/// Workspaces looked at in one daily run, so one run never runs long; the
289+/// next run carries on after the last one.
290+pub const WORKSPACES_PER_RUN: u32 = 200;
291+/// Workspaces asked about in one call to billing, which reads each one's
292+/// account and plan.
293+const ASK_AT_ONCE: usize = 50;
283294 /// Rows removed per statement, so one purge never runs long.
284295 const PURGE_BATCH: u32 = 5_000;
285296
288299 g1t_contracts::time::rfc3339(now_ms.saturating_sub(u64::from(keep_days) * 24 * 60 * 60 * 1000))
289300 }
290301
302+/// Each workspace with the time its entries are kept from. Its days are
303+/// held between the shortest and the longest any workspace keeps: fewer
304+/// than the shortest would not be looked for, and more than the longest
305+/// are deleted anyway.
306+pub fn cutoffs(
307+ now_ms: u64,
308+ retention: &[AuditRetention],
309+ min_days: u32,
310+ max_days: u32,
311+) -> Vec<(String, String)> {
312+ retention
313+ .iter()
314+ .map(|r| {
315+ let days = r.days.max(min_days).min(max_days);
316+ (r.workspace.clone(), keep_from(now_ms, days))
317+ })
318+ .collect()
319+}
320+
291321 /// Removes entries older than every plan keeps, a batch at a time, up to
292322 /// `rounds` batches. Returns how many went.
293323 pub async fn purge(db: &D1Database, before: &str, rounds: u32) -> Result<u32> {
310340 Ok(removed)
311341 }
312342
343+/// Removes one workspace's entries older than `before`, the same way.
344+async fn purge_workspace(
345+ db: &D1Database,
346+ workspace: &str,
347+ before: &str,
348+ rounds: u32,
349+) -> Result<u32> {
350+ let mut removed = 0;
351+ for _ in 0..rounds {
352+ let result = db
353+ .prepare(
354+ "DELETE FROM audit_entries WHERE id IN
355+ (SELECT id FROM audit_entries WHERE workspace = ? AND time < ? ORDER BY time LIMIT ?)",
356+ )
357+ .bind(&[workspace.into(), before.into(), PURGE_BATCH.into()])?
358+ .run()
359+ .await?;
360+ let changed = result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) as u32;
361+ removed += changed;
362+ if changed < PURGE_BATCH {
363+ break;
364+ }
365+ }
366+ Ok(removed)
367+}
368+
369+/// Up to `limit` workspaces after `after`, in order, that have entries
370+/// older than `before`. Each step seeks the next workspace in the index on
371+/// (workspace, time) rather than reading every row, which a DISTINCT over
372+/// the rows older than a week would: a workspace on the plan always has
373+/// weeks of them.
374+async fn workspaces_past(
375+ db: &D1Database,
376+ after: &str,
377+ before: &str,
378+ limit: u32,
379+) -> Result<Vec<String>> {
380+ #[derive(Deserialize)]
381+ struct Found {
382+ workspace: String,
383+ }
384+ Ok(db
385+ .prepare(
386+ "WITH RECURSIVE w(workspace) AS (
387+ SELECT (SELECT MIN(workspace) FROM audit_entries WHERE workspace > ?1)
388+ UNION ALL
389+ SELECT (SELECT MIN(e.workspace) FROM audit_entries e WHERE e.workspace > w.workspace)
390+ FROM w WHERE w.workspace IS NOT NULL
391+ )
392+ SELECT workspace FROM w
393+ WHERE workspace IS NOT NULL
394+ AND EXISTS (SELECT 1 FROM audit_entries a WHERE a.workspace = w.workspace AND a.time < ?2)
395+ LIMIT ?3",
396+ )
397+ .bind(&[after.into(), before.into(), limit.into()])?
398+ .all()
399+ .await?
400+ .results::<Found>()?
401+ .into_iter()
402+ .map(|found| found.workspace)
403+ .collect())
404+}
405+
406+/// Removes each workspace's entries older than its plan keeps, for up to
407+/// `WORKSPACES_PER_RUN` workspaces after where the last run stopped. Their
408+/// days come from billing; if it cannot be reached, nothing is removed and
409+/// the next run tries the same workspaces again. Returns how many went.
410+pub async fn purge_by_plan(
411+ db: &D1Database,
412+ billing: &Fetcher,
413+ now_ms: u64,
414+ min_days: u32,
415+ max_days: u32,
416+) -> Result<u32> {
417+ #[derive(Deserialize)]
418+ struct Cursor {
419+ after: String,
420+ }
421+ let after = db
422+ .prepare("SELECT after FROM audit_purge_cursor WHERE id = 1")
423+ .first::<Cursor>(None)
424+ .await?
425+ .map_or_else(String::new, |cursor| cursor.after);
426+ let found =
427+ workspaces_past(db, &after, &keep_from(now_ms, min_days), WORKSPACES_PER_RUN).await?;
428+ // Every workspace's days are asked for before anything is removed, so a
429+ // billing that cannot be reached removes nothing at all.
430+ let mut retention: Vec<AuditRetention> = Vec::with_capacity(found.len());
431+ for chunk in found.chunks(ASK_AT_ONCE) {
432+ let args = AuditRetentionArgs {
433+ workspaces: chunk.to_vec(),
434+ };
435+ let answered: Vec<AuditRetention> =
436+ g1t_kit::call(billing, "audit_retention", &args).await?;
437+ retention.extend(answered);
438+ }
439+ let mut removed = 0;
440+ for (workspace, before) in cutoffs(now_ms, &retention, min_days, max_days) {
441+ removed += purge_workspace(db, &workspace, &before, 4).await?;
442+ }
443+ // A short page means the end was reached: the next run starts over.
444+ let next = if found.len() < WORKSPACES_PER_RUN as usize {
445+ String::new()
446+ } else {
447+ found.last().cloned().unwrap_or_default()
448+ };
449+ db.prepare(
450+ "INSERT INTO audit_purge_cursor (id, after) VALUES (1, ?1)
451+ ON CONFLICT (id) DO UPDATE SET after = ?1",
452+ )
453+ .bind(&[next.into()])?
454+ .run()
455+ .await?;
456+ Ok(removed)
457+}
458+
313459 /// Moves a renamed workspace's rows to its new slug.
314460 pub async fn follow_renames(db: &D1Database, events: &[Event]) -> Result<()> {
315461 for event in events
352498 // 2026-10-05T00:00:00Z, a year back.
353499 let now = 1_791_158_400_000;
354500 assert_eq!(keep_from(now, 365), "2025-10-05T00:00:00.000Z");
355− assert_eq!(DEFAULT_KEEP_DAYS, 90);
501+ assert_eq!(DEFAULT_MAX_DAYS, 400);
502+ assert_eq!(DEFAULT_MIN_DAYS, 7);
503+ }
504+
505+ #[test]
506+ fn each_workspace_is_cut_off_at_its_own_days() {
507+ // 2026-10-05T00:00:00Z.
508+ let now = 1_791_158_400_000;
509+ let kept = |workspace: &str, days: u32| AuditRetention {
510+ workspace: workspace.into(),
511+ days,
512+ };
513+ let retention = [
514+ kept("free", 7),
515+ kept("plan", 90),
516+ kept("longer", 365),
517+ kept("shorter", 1),
518+ kept("past-the-most", 1_000),
519+ ];
520+ let expected = [
521+ ("free", "2026-09-28T00:00:00.000Z"),
522+ ("plan", "2026-07-07T00:00:00.000Z"),
523+ ("longer", "2025-10-05T00:00:00.000Z"),
524+ // Fewer days than the shortest are never looked for.
525+ ("shorter", "2026-09-28T00:00:00.000Z"),
526+ // More than the most are deleted by the ceiling anyway.
527+ ("past-the-most", "2025-08-31T00:00:00.000Z"),
528+ ];
529+ let expected: Vec<(String, String)> = expected
530+ .iter()
531+ .map(|(w, t)| ((*w).to_owned(), (*t).to_owned()))
532+ .collect();
533+ assert_eq!(cutoffs(now, &retention, 7, 400), expected);
356534 }
357535
358536 fn args() -> ListAuditArgs {
+37−9
209209 Ok(())
210210 }
211211
212−/// Once a day: audit entries older than the audit log keeps are removed
213−/// (`AUDIT_KEEP_DAYS`, 90 days by default, the same on every plan).
212+/// Once a day, old audit entries are removed: first everything older than
213+/// any workspace keeps (`AUDIT_MAX_DAYS`, 400 days), then each workspace's
214+/// entries older than its own plan keeps, as billing says (7 days free, 90
215+/// on the plan, or what staff set). Workspaces with nothing older than the
216+/// shortest (`AUDIT_MIN_DAYS`, 7) are left alone.
214217 #[event(scheduled)]
215218 async fn scheduled(_event: worker::ScheduledEvent, env: Env, _ctx: worker::ScheduleContext) {
216− let keep_days = env
217− .var("AUDIT_KEEP_DAYS")
218− .ok()
219− .and_then(|v| v.to_string().parse().ok())
220− .unwrap_or(audit::DEFAULT_KEEP_DAYS);
219+ let days = |name: &str, default: u32| {
220+ env.var(name)
221+ .ok()
222+ .and_then(|v| v.to_string().trim().parse::<u32>().ok())
223+ .filter(|days| *days > 0)
224+ .unwrap_or(default)
225+ };
226+ let max_days = days("AUDIT_MAX_DAYS", audit::DEFAULT_MAX_DAYS);
227+ let min_days = days("AUDIT_MIN_DAYS", audit::DEFAULT_MIN_DAYS).min(max_days);
221228 let Ok(db) = env.d1("DB") else { return };
222− match audit::purge(&db, &audit::keep_from(now_ms(), keep_days), 20).await {
223− Ok(removed) if removed > 0 => worker::console_log!("removed {removed} audit entries older than {keep_days} days"),
229+ let now = now_ms();
230+ match audit::purge(&db, &audit::keep_from(now, max_days), 20).await {
231+ Ok(removed) if removed > 0 => {
232+ worker::console_log!("removed {removed} audit entries older than {max_days} days")
233+ }
224234 Ok(_) => {}
225235 Err(error) => worker::console_error!("could not remove old audit entries: {error}"),
226236 }
237+ // Without billing nobody's plan is known, so nothing younger than the
238+ // ceiling is removed.
239+ let billing = match env.service("BILLING") {
240+ Ok(billing) => billing,
241+ Err(error) => {
242+ worker::console_error!(
243+ "audit entries kept past their plan's days: no billing: {error}"
244+ );
245+ return;
246+ }
247+ };
248+ match audit::purge_by_plan(&db, &billing, now, min_days, max_days).await {
249+ Ok(removed) if removed > 0 => {
250+ worker::console_log!("removed {removed} audit entries older than their plan keeps")
251+ }
252+ Ok(_) => {}
253+ Err(error) => worker::console_error!("audit entries kept past their plan's days: {error}"),
254+ }
227255 }
+8−4
3838 ],
3939 "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1 }]
4040 },
41− // Once a day, audit entries older than the audit log reads back are
42− // removed. The same on every plan: keep it at least billing's
43− // AUDIT_RETENTION_DAYS.
41+ // Each workspace's audit log keeps as many days as billing says (7 free,
42+ // 90 on the plan, or what staff set), asked for over this binding.
43+ "services": [{ "binding": "BILLING", "service": "g1t-billing" }],
44+ // Once a day, audit entries older than AUDIT_MAX_DAYS are removed for
45+ // everyone (keep it at least billing's AUDIT_MAX_DAYS), then each
46+ // workspace's older than its own days. Only workspaces with entries
47+ // older than AUDIT_MIN_DAYS, the shortest any plan keeps, are asked about.
4448 "triggers": { "crons": ["41 3 * * *"] },
45− "vars": { "AUDIT_KEEP_DAYS": "90" },
49+ "vars": { "AUDIT_MAX_DAYS": "400", "AUDIT_MIN_DAYS": "7" },
4650 "observability": { "enabled": true }
4751 }
+4−0
1+-- The person a run is for, by username: who asked g1t for the work. The
2+-- model proxy reports each run's tokens under them, for usage views. Null
3+-- when nobody asked, and never g1t's own agent.
4+ALTER TABLE model_sessions ADD COLUMN requested_by TEXT;
+23−3
123123 out
124124 }
125125
126+/// Who a run is for, as kept on its session: a username, lowercased. The
127+/// agent's own name is not a person, so it is kept as nobody.
128+fn requester(username: Option<&str>) -> Option<String> {
129+ let name = username?.trim().to_lowercase();
130+ (!name.is_empty() && name != g1t_contracts::identity::AGENT_NAME).then_some(name)
131+}
132+
126133 /// A model session's public id: the start of its token's hash.
127134 fn session_id(token_hash: &str) -> String {
128135 format!("ms_{}", &token_hash[..token_hash.len().min(24)])
139146 model: Option<String>,
140147 #[serde(default)]
141148 tier: Option<String>,
149+ #[serde(default)]
150+ requested_by: Option<String>,
142151 }
143152
144153 #[derive(Deserialize)]
12291238 .bind(&[rfc3339(now).into()])?,
12301239 self.db
12311240 .prepare(
1232− "INSERT INTO model_sessions (token_hash, workspace, connection_id, repo, number, task, expires_at, model, tier)
1233− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1241+ "INSERT INTO model_sessions (token_hash, workspace, connection_id, repo, number, task, expires_at, model, tier, requested_by)
1242+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
12341243 )
12351244 .bind(&[
12361245 crypto::sha256_hex(&token).into(),
12471256 .as_deref()
12481257 .filter(|tier| connection.is_none() && matches!(*tier, "small" | "large")),
12491258 ),
1259+ optional(requester(a.requested_by.as_deref()).as_deref()),
12501260 ])?,
12511261 ])
12521262 .await?;
12821292 task: session.task,
12831293 session: session_id(&session.token_hash),
12841294 tier: session.tier,
1295+ requested_by: session.requested_by,
12851296 base_url: None,
12861297 api_key: None,
12871298 auth_header: None,
14901501
14911502 #[cfg(test)]
14921503 mod close_tests {
1493− use super::closable_hashes;
1504+ use super::{closable_hashes, requester};
1505+
1506+ #[test]
1507+ fn a_session_is_for_a_person_never_the_agent() {
1508+ assert_eq!(requester(Some(" Ada ")).as_deref(), Some("ada"));
1509+ assert_eq!(requester(Some("g1t")), None);
1510+ assert_eq!(requester(Some("G1T")), None);
1511+ assert_eq!(requester(Some(" ")), None);
1512+ assert_eq!(requester(None), None);
1513+ }
14941514
14951515 #[test]
14961516 fn only_token_hashes_are_closed() {
+28−5
1111 * the run ends (the runner closes its session then, and lookups are kept
1212 * only seconds), and nothing of the workspace's.
1313 *
14− * Responses stream through.
14+ * Responses stream through. What each answer used is read from a copy as
15+ * it passes and reported to billing afterwards, counted per run for usage
16+ * views.
1517 */
16−import { type ModelUpstream, type ServiceBinding, integrationsClient } from "@g1t/contracts";
18+import { type ModelUpstream, type ServiceBinding, billingClient, integrationsClient } from "@g1t/contracts";
1719
1820 import { type AnthropicRequest, StreamTranslator, errorFromChat, estimateTokens, fromChat, toChat } from "./openai";
21+import { isAnswer, tokenReport } from "./report";
1922 import { type HostedRouting, presentedToken, upstreamRequest } from "./route";
23+import { measure } from "./usage";
2024
2125 interface Env extends HostedRouting {
2226 INTEGRATIONS: ServiceBinding;
27+ BILLING: ServiceBinding;
2328 }
2429
2530 /**
4853 );
4954 }
5055
56+/**
57+ * Passes an answer through and, once it has all gone by, tells billing what
58+ * it used. Reporting happens after the answer, and a report that fails is
59+ * dropped: the answer never waits on it or breaks for it.
60+ */
61+function counted(answer: Response, upstream: ModelUpstream, env: Env, ctx: ExecutionContext): Response {
62+ const { response, tokens, model } = measure(answer);
63+ ctx.waitUntil(
64+ (async () => {
65+ const report = tokenReport(upstream, await model, await tokens);
66+ if (report) await billingClient(env.BILLING).recordTokens(report);
67+ })().catch(() => undefined),
68+ );
69+ return response;
70+}
71+
5172 /** Sends an Anthropic request to a provider that speaks OpenAI's API. */
5273 async function viaChat(upstream: ModelUpstream, path: string, request: Request): Promise<Response> {
5374 const body = (await request.json()) as AnthropicRequest;
95116 }
96117
97118 export default {
98− async fetch(request: Request, env: Env): Promise<Response> {
119+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
99120 const url = new URL(request.url);
100121 if (url.pathname === "/" || url.pathname === "") {
101122 return new Response("g1t's model proxy, for g1t's sandboxes. See https://docs.g1t.sh/guides/models/\n");
107128 if (!upstream) return refuse(401, "This run's model token has expired, or its model connection was removed.");
108129
109130 const path = url.pathname.slice("/anthropic".length) + url.search;
110− if (upstream.api === "openai") return viaChat(upstream, path, request);
131+ // Both routes answer in Anthropic's shape, so one reading counts either.
132+ const answer = (response: Response) => (isAnswer(url.pathname.slice("/anthropic".length)) ? counted(response, upstream, env, ctx) : response);
133+ if (upstream.api === "openai") return answer(await viaChat(upstream, path, request));
111134
112135 const { url: target, headers } = upstreamRequest(upstream, env, path, request.headers);
113136 // A route that names a model gets it for every request of the run,
118141 body = JSON.stringify({ ...parsed, model: upstream.model });
119142 headers.delete("content-length");
120143 }
121− return fetch(target, { method: request.method, headers, body });
144+ return answer(await fetch(target, { method: request.method, headers, body }));
122145 },
123146 } satisfies ExportedHandler<Env>;
+64−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { ModelUpstream } from "@g1t/contracts";
5+
6+import { isAnswer, tokenReport } from "./report.ts";
7+import { NO_TOKENS, measure } from "./usage.ts";
8+
9+const upstream: ModelUpstream = {
10+ route: "g1t",
11+ api: "anthropic",
12+ model: null,
13+ official: false,
14+ provider: "g1t",
15+ workspace: "acme",
16+ repo: "acme/web",
17+ number: 7,
18+ task: "implement",
19+ session: "ms_abc",
20+ tier: "large",
21+ requestedBy: "ada",
22+ baseUrl: null,
23+ apiKey: null,
24+ authHeader: null,
25+};
26+
27+test("only messages are answers; counting tokens is not", () => {
28+ assert.equal(isAnswer("/v1/messages"), true);
29+ assert.equal(isAnswer("/v1/messages?beta=true"), true);
30+ assert.equal(isAnswer("/v1/messages/count_tokens?beta=true"), false);
31+ assert.equal(isAnswer("/v1/models"), false);
32+});
33+
34+test("an answer's tokens are reported under its run and person", () => {
35+ const report = tokenReport(upstream, "claude-opus-4", { input: 3, output: 9, cacheRead: 100, cacheWrite: 0 });
36+ assert.deepEqual(report, {
37+ workspace: "acme",
38+ session: "ms_abc",
39+ person: "ada",
40+ model: "claude-opus-4",
41+ tier: "large",
42+ input: 3,
43+ output: 9,
44+ cacheRead: 100,
45+ cacheWrite: 0,
46+ });
47+ // A route that names a model counts under it.
48+ assert.equal(tokenReport({ ...upstream, model: "gpt-x", tier: null }, "other", { ...NO_TOKENS, output: 1 })?.model, "gpt-x");
49+});
50+
51+test("nothing used, or no session to count it under, is not reported", () => {
52+ assert.equal(tokenReport(upstream, null, { ...NO_TOKENS }), null);
53+ assert.equal(tokenReport({ ...upstream, session: "" }, null, { ...NO_TOKENS, input: 1 }), null);
54+});
55+
56+test("the model that answered is read from the answer", async () => {
57+ const body = `data: ${JSON.stringify({ type: "message_start", message: { model: "claude-x", usage: { input_tokens: 1 } } })}\n\n`;
58+ const streamed = measure(new Response(body, { headers: { "content-type": "text/event-stream" } }));
59+ await streamed.response.text();
60+ assert.equal(await streamed.model, "claude-x");
61+ const whole = measure(Response.json({ model: "claude-y", usage: { output_tokens: 2 } }));
62+ await whole.response.text();
63+ assert.equal(await whole.model, "claude-y");
64+});
+49−0
1+/**
2+ * What the proxy tells billing about one answer: its tokens, under the
3+ * run's session and the person it is for, for usage views. Billing still
4+ * prices runs from AI Gateway, not from these.
5+ */
6+import type { ModelUpstream } from "@g1t/contracts";
7+
8+import type { Tokens } from "./usage";
9+
10+export type TokenReport = {
11+ workspace: string;
12+ session: string;
13+ person: string | null;
14+ model: string;
15+ tier: "small" | "large" | null;
16+ input: number;
17+ output: number;
18+ cacheRead: number;
19+ cacheWrite: number;
20+};
21+
22+/**
23+ * Whether a request's answer is a model's answer, and so used tokens.
24+ * Counting tokens is a question about a request, not an answer.
25+ */
26+export function isAnswer(path: string): boolean {
27+ return /^\/v1\/messages\/?(\?|$)/.test(path);
28+}
29+
30+/**
31+ * The report for one answer, or null when it used nothing or its session
32+ * has no id to count it under. The model is the run's when its route names
33+ * one, else the one that answered.
34+ */
35+export function tokenReport(upstream: ModelUpstream, answeredBy: string | null, tokens: Tokens): TokenReport | null {
36+ const used = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite;
37+ if (used === 0 || !upstream.session) return null;
38+ return {
39+ workspace: upstream.workspace,
40+ session: upstream.session,
41+ person: upstream.requestedBy ?? null,
42+ model: upstream.model ?? answeredBy ?? "unknown",
43+ tier: upstream.tier ?? null,
44+ input: tokens.input,
45+ output: tokens.output,
46+ cacheRead: tokens.cacheRead,
47+ cacheWrite: tokens.cacheWrite,
48+ };
49+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.