Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
6 commits
- recommendationsChase Piercef9e8c2d
- Usage's cost shows comped runs at what they cost the provider, not $0Chase Pierceb93ad0e
- Mission control shows model usage, yours and the workspace's: tokens, cost, active days, cache share, each day, and the mixChase Pierceee65320
- The model proxy can read what an answer used: Anthropic's usage from a whole answer or a streamChase Piercedb99f0d
- Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudoChase Pierce7956b44
- Fewer Artifacts reads: the store is asked for a handle only when needed, objects are kept in the isolate, and issue events read no workflows nobody listens forChase Pierce2e7b4ce
55 files+1571−720/55 viewed
| 1 | 1 | { | |
| 2 | − | "recommendations": [ | |
| 3 | − | ] | |
| 2 | + | "recommendations": ["astro-build.astro-vscode"] | |
| 4 | 3 | } |
| 75 | 75 | ||
| 76 | 76 | ## How long it is kept | |
| 77 | 77 | ||
| 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. | |
| 81 | 98 | ||
| 82 | 99 | ## Export | |
| 83 | 100 |
| 112 | 112 | work is routed to, translates if the provider speaks OpenAI's API, and | |
| 113 | 113 | forwards the request. Answers stream straight back. | |
| 114 | 114 | ||
| 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 | + | ||
| 115 | 120 | The token stops working within seconds of the run finishing, however it | |
| 116 | 121 | ends, and within seconds if you disconnect the provider. A run whose end | |
| 117 | 122 | g1t never hears about loses it three hours after it starts. Keys are sealed when you save them, and used |
| 20 | 20 | - [Security scans](#storage-search-embeddings-and-scans) of history and | |
| 21 | 21 | dependencies, which g1t pays for on a free workspace. | |
| 22 | 22 | - 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. | |
| 24 | 25 | - Your own agent through [MCP](/reference/mcp/). | |
| 25 | 26 | - 1 GB of private repository storage, and 50,000 | |
| 26 | 27 | [git operations](#git-operations) a month. | |
| 52 | 53 | - **Unlimited** projects, previews and repositories. | |
| 53 | 54 | - Agents, workflows, the merge queue, | |
| 54 | 55 | [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. | |
| 55 | 58 | - Usage past $10 is charged at cost plus 20%, **up to your | |
| 56 | 59 | [spend limit](#limits)**. | |
| 57 | 60 | ||
| 576 | 579 | ## Security on every plan | |
| 577 | 580 | ||
| 578 | 581 | 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 | |
| 581 | 586 | built yet; when it is, it will be on every plan. | |
| 582 | 587 | ||
| 583 | 588 | ## Enterprises and custom terms | |
| 597 | 602 | stays accurate. | |
| 598 | 603 | - **Custom**: a discount on usage, a limit of its own, or a larger share | |
| 599 | 604 | 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). | |
| 600 | 608 | ||
| 601 | 609 | g1t's own workspaces and those of Flagon, Inc., the company that makes g1t, | |
| 602 | 610 | run comped. Their usage is recorded at cost, apart from what customers |
| 193 | 193 | | Webhooks, integrations, secrets and variables | The workspace's own are removed. | | |
| 194 | 194 | | Memory and guardrails | The workspace's own are removed. | | |
| 195 | 195 | | 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. | | |
| 197 | 197 | | Recently deleted repositories | Purged with it, their git data with them. | | |
| 198 | 198 | | Old addresses | Redirects for repositories transferred out keep working. The workspace's own pages answer 404. | | |
| 199 | 199 |
| 284 | 284 | } | |
| 285 | 285 | if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`); | |
| 286 | 286 | 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`); | |
| 287 | 288 | if (allowances.hold) lines.push(`Held: ${allowances.hold}`); | |
| 288 | 289 | return lines; | |
| 289 | 290 | } | |
| 291 | 292 | /** | |
| 292 | 293 | * The g1t plan without its price, the account's share of g1t's pools, and | |
| 293 | 294 | * 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. | |
| 295 | 298 | */ | |
| 296 | 299 | export function AllowancesForm({ | |
| 297 | 300 | allowances, | |
| 365 | 368 | <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10."> | |
| 366 | 369 | <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} /> | |
| 367 | 370 | </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> | |
| 368 | 380 | <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold."> | |
| 369 | 381 | <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} /> | |
| 370 | 382 | </Field> |
| 102 | 102 | test("allowances: the plan without its price, pool shares, and staff's overrides", () => { | |
| 103 | 103 | assert.deepEqual(parseAllowances(form({})), { | |
| 104 | 104 | 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 | + | }, | |
| 106 | 115 | }); | |
| 107 | 116 | 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 " })), | |
| 109 | 118 | { | |
| 110 | 119 | ok: true, | |
| 111 | 120 | value: { | |
| 115 | 124 | maxConcurrentAgents: 20, | |
| 116 | 125 | runCapMicros: 25_000_000, | |
| 117 | 126 | issueCapMicros: 500_000_000, | |
| 127 | + | auditRetentionDays: 365, | |
| 118 | 128 | hold: "Card disputed; waiting on the bank", | |
| 119 | 129 | }, | |
| 120 | 130 | }, | |
| 127 | 137 | assert.equal(parseAllowances(form({ runCap: "0.05" })).ok, false); | |
| 128 | 138 | assert.equal(parseAllowances(form({ runCap: "1000.01" })).ok, false); | |
| 129 | 139 | 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); | |
| 130 | 147 | assert.equal(parseAllowances(form({ hold: "x".repeat(201) })).ok, false); | |
| 131 | 148 | }); | |
| 132 | 149 |
| 184 | 184 | /** Staff's overrides of the owners' caps: one run, and one issue's agents in all. */ | |
| 185 | 185 | export const MAX_RUN_CAP_MICROS = 1_000 * MICROS_PER_DOLLAR; | |
| 186 | 186 | 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; | |
| 187 | 192 | /** The smallest cap: ten cents, as owners may set. */ | |
| 188 | 193 | const MIN_CAP_MICROS = 100_000; | |
| 189 | 194 | const MAX_HOLD = 200; | |
| 191 | 196 | /** | |
| 192 | 197 | * Allowances from the plan-and-pools form: the g1t plan without its price, | |
| 193 | 198 | * 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. | |
| 197 | 203 | */ | |
| 198 | 204 | export function parseAllowances(form: FormData): Parsed<Allowances> { | |
| 199 | 205 | const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => { | |
| 223 | 229 | maxConcurrentAgents = Number(rawAgents); | |
| 224 | 230 | } | |
| 225 | 231 | ||
| 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 | + | ||
| 226 | 241 | const hold = text(form, "hold").replace(/\s+/g, " "); | |
| 227 | 242 | if (hold.length > MAX_HOLD) return { ok: false, error: `Keep the hold's reason under ${MAX_HOLD} characters.` }; | |
| 228 | 243 | ||
| 235 | 250 | maxConcurrentAgents, | |
| 236 | 251 | runCapMicros: runCap.value, | |
| 237 | 252 | issueCapMicros: issueCap.value, | |
| 253 | + | auditRetentionDays, | |
| 238 | 254 | hold: hold || null, | |
| 239 | 255 | }, | |
| 240 | 256 | }; |
| 538 | 538 | ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}` | |
| 539 | 539 | : "—", | |
| 540 | 540 | ], | |
| 541 | − | ["Audit log", `${e.auditRetentionDays} days`], | |
| 541 | + | ["Audit log", `${e.auditRetentionDays} days${e.auditRetentionCustom ? ", set by staff" : ""}`], | |
| 542 | 542 | ]; | |
| 543 | 543 | return ( | |
| 544 | 544 | <Section |
| 157 | 157 | { | |
| 158 | 158 | icon: <ScrollText size={18} />, | |
| 159 | 159 | 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.", | |
| 161 | 161 | to: `${DOCS}/guides/audit-log/`, | |
| 162 | 162 | }, | |
| 163 | 163 | ]; |
| 32 | 32 | import type { ShellData } from "./shell"; | |
| 33 | 33 | import { useLiveRefresh } from "./agents"; | |
| 34 | 34 | import { Unavailable } from "./mission"; | |
| 35 | + | import { TokenUsagePanel } from "./token-usage"; | |
| 35 | 36 | import { Avatar, TimeAgo } from "./ui"; | |
| 36 | 37 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 37 | 38 | import type { Loaded } from "../routes/home"; | |
| 1093 | 1094 | )} | |
| 1094 | 1095 | </section> | |
| 1095 | 1096 | ||
| 1097 | + | {workspace && ( | |
| 1098 | + | <TokenUsagePanel | |
| 1099 | + | workspace={loaderData.tokens.workspace} | |
| 1100 | + | mine={loaderData.tokens.mine} | |
| 1101 | + | usageHref={`/${workspace}/-/usage`} | |
| 1102 | + | /> | |
| 1103 | + | )} | |
| 1104 | + | ||
| 1096 | 1105 | <section id="activity" className="scroll-mt-20 rounded-xl border border-line bg-surface p-5"> | |
| 1097 | 1106 | <div className="flex items-center justify-between"> | |
| 1098 | 1107 | <h2 className="text-base font-semibold tracking-tight">Activity</h2> |
| 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 | + | } |
| 11 | 11 | export const audit = auditClient(instrumented("events", env.EVENTS)); | |
| 12 | 12 | ||
| 13 | 13 | /** | |
| 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. | |
| 16 | 17 | */ | |
| 17 | 18 | export async function auditRetention(workspace: string): Promise<number | null> { | |
| 18 | 19 | const found = await billing.entitlements(workspace.toLowerCase()).catch(() => null); |
| 93 | 93 | ||
| 94 | 94 | test("the log reads back only as far as the plan keeps it", () => { | |
| 95 | 95 | const now = Date.parse("2026-10-31T00:00:00.000Z"); | |
| 96 | − | // 90 days, on every plan. | |
| 96 | + | // 90 days on the plan, 7 free. | |
| 97 | 97 | 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"); | |
| 98 | 99 | assert.equal(retainedSince(null, 30, now), "2026-10-01T00:00:00.000Z"); | |
| 99 | 100 | assert.equal(retainedSince("2026-01-01T00:00:00.000Z", 30, now), "2026-10-01T00:00:00.000Z"); | |
| 100 | 101 | // A later start than the window is kept. |
| 26 | 26 | ||
| 27 | 27 | /** | |
| 28 | 28 | * 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. | |
| 31 | 31 | */ | |
| 32 | 32 | export function retainedSince(since: string | null | undefined, days: number, now = Date.now()): string { | |
| 33 | 33 | const start = new Date(now - days * 24 * 60 * 60 * 1000).toISOString(); |
| 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 | + | }); |
| 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 | + | } |
| 202 | 202 | ); | |
| 203 | 203 | }); | |
| 204 | 204 | ||
| 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([ | |
| 206 | 206 | reposP, | |
| 207 | 207 | soft("projects", perRepoP), | |
| 208 | 208 | soft("pulls", work.listActivePulls(viewer)), | |
| 215 | 215 | slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null, | |
| 216 | 216 | // Repositories someone has invited the viewer to. | |
| 217 | 217 | 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, | |
| 218 | 221 | ]); | |
| 219 | 222 | ||
| 220 | 223 | const repoList = repos ?? []; | |
| 598 | 601 | weekCost, | |
| 599 | 602 | }, | |
| 600 | 603 | week, | |
| 604 | + | tokens: { workspace: okOr(tokens), mine: okOr(myTokens) }, | |
| 601 | 605 | groups, | |
| 602 | 606 | titles: shownTitles, | |
| 603 | 607 | // A run that is going makes the page worth refreshing on its own. |
| 49 | 49 | ossPoolMicros: 25_000_000, | |
| 50 | 50 | ossRepoMicros: 2_000_000, | |
| 51 | 51 | freePrivateStorageBytes: 1_000_000_000, | |
| 52 | − | auditRetentionDays: 90, | |
| 52 | + | auditRetentionDays: 7, | |
| 53 | + | planAuditRetentionDays: 90, | |
| 53 | 54 | minChargeMicros: 5_000_000, | |
| 54 | 55 | gitOperationsIncluded: 50_000, | |
| 55 | 56 | paidStartCeilingMicros: 100_000_000, | |
| 143 | 144 | { | |
| 144 | 145 | what: "Audit log", | |
| 145 | 146 | 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.", | |
| 147 | 149 | }, | |
| 148 | 150 | { what: "Secret push protection", free: "Included", plan: "Included" }, | |
| 149 | 151 | { what: "Single sign-on", free: "On every plan, once it is built", plan: "On every plan, once it is built" }, | |
| 460 | 462 | <li> | |
| 461 | 463 | <p className="font-medium">Security on every plan</p> | |
| 462 | 464 | <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. | |
| 464 | 467 | </p> | |
| 465 | 468 | </li> | |
| 466 | 469 | <li> |
| 91 | 91 | ? " As an owner you see the whole workspace." | |
| 92 | 92 | : " As a member you see what was done to the workspace's projects, and what was done by you or on your behalf."} | |
| 93 | 93 | {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.`} | |
| 95 | 95 | </p> | |
| 96 | 96 | ||
| 97 | 97 | <Form method="get" className="mt-6 rounded-xl border border-line bg-surface p-4"> |
| 350 | 350 | pub added_micros: i64, | |
| 351 | 351 | } | |
| 352 | 352 | ||
| 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 | + | ||
| 353 | 426 | /// What a workspace pays a monthly price for. There is one plan, `plan` | |
| 354 | 427 | /// ("g1t"): a flat price per workspace, never per person, with included | |
| 355 | 428 | /// usage each month, more private storage, and deployments. Never free: | |
| 716 | 789 | /// it, the plan pays at cost plus the margin; a free workspace's pushes | |
| 717 | 790 | /// to private repositories stop instead. | |
| 718 | 791 | 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. | |
| 720 | 793 | 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, | |
| 721 | 798 | /// The smallest amount a card is charged when a month closes; less | |
| 722 | 799 | /// carries over. Charges at a limit always go through. | |
| 723 | 800 | pub min_charge_micros: i64, | |
| 788 | 865 | /// `ISSUE_CAP_MICROS` and the owners' own. None: theirs, or the default. | |
| 789 | 866 | #[serde(default)] | |
| 790 | 867 | 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>, | |
| 791 | 872 | /// A hold g1t staff put on new compute, with why. None: no hold. | |
| 792 | 873 | #[serde(default)] | |
| 793 | 874 | pub hold: Option<String>, | |
| 921 | 1002 | pub workspace: String, | |
| 922 | 1003 | } | |
| 923 | 1004 | ||
| 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 | + | ||
| 924 | 1019 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 925 | 1020 | #[serde(rename_all = "camelCase")] | |
| 926 | 1021 | pub struct Entitlements { | |
| 973 | 1068 | pub included_micros: i64, | |
| 974 | 1069 | #[serde(default)] | |
| 975 | 1070 | 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. | |
| 978 | 1073 | 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, | |
| 979 | 1078 | /// Private repository storage that is free for every workspace: past | |
| 980 | 1079 | /// it, the plan pays for it and a free workspace's pushes stop. | |
| 981 | 1080 | pub free_private_storage_bytes: i64, |
| 387 | 387 | /// For `g1t`: the tier the run was routed to, `small` or `large`. | |
| 388 | 388 | #[serde(default)] | |
| 389 | 389 | 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>, | |
| 390 | 394 | /// For `endpoint`: where to send requests. | |
| 391 | 395 | pub base_url: Option<String>, | |
| 392 | 396 | /// For `anthropic` and `endpoint`: the workspace's key. | |
| 566 | 570 | /// when the run goes to g1t's models. | |
| 567 | 571 | #[serde(default)] | |
| 568 | 572 | 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>, | |
| 569 | 577 | } | |
| 570 | 578 | ||
| 571 | 579 | /// `routes`: a workspace's model routes, one per kind of work that has its |
| 413 | 413 | 1,000, today's demo-scale traffic alone is about 3.2 million a month (~$480). Ask Cloudflare | |
| 414 | 414 | (Q1) before 2026-10-14. Either way the volume is mostly waste: every repos call opens a handle | |
| 415 | 415 | 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. | |
| 419 | 424 | - 476 client errors on 2026-10-06 are unexplained; the fetch fix below accounts for some (every | |
| 420 | 425 | failed negotiation was one). | |
| 421 | 426 |
| 200 | 200 | emailed again weekly. The red bar on every sudo page shows margin, | |
| 201 | 201 | overall and leak alerts. | |
| 202 | 202 | ||
| 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 | + | ||
| 203 | 221 | ## Tables (migration `0022_costs_and_margin.sql`) | |
| 204 | 222 | ||
| 205 | 223 | `cost_lines`, `cost_map`, `revenue_map`, `own_counts`, |
| 127 | 127 | runCapMicros?: number | null; | |
| 128 | 128 | /** What one issue's agents may spend in all, in place of the owners' and the default $10; null for none. */ | |
| 129 | 129 | 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; | |
| 130 | 132 | /** A hold on new compute, with why; null for none. */ | |
| 131 | 133 | hold?: string | null; | |
| 132 | 134 | }; | |
| 209 | 211 | /** The plan's included usage each month, and what of it is used. */ | |
| 210 | 212 | includedMicros?: number; | |
| 211 | 213 | 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. */ | |
| 213 | 215 | auditRetentionDays: number; | |
| 216 | + | /** Whether `auditRetentionDays` is what staff set for the account rather than the plan's. */ | |
| 217 | + | auditRetentionCustom?: boolean; | |
| 214 | 218 | /** Private repository storage free for every workspace: past it, the plan pays and a free workspace's pushes stop. */ | |
| 215 | 219 | freePrivateStorageBytes: number; | |
| 216 | 220 | /** The last daily measure of the workspace's private repositories (a lower bound). */ | |
| 680 | 684 | ossRepoMicros: number; | |
| 681 | 685 | /** Private repository storage free for every workspace. Past it, the plan pays; a free workspace's pushes stop. */ | |
| 682 | 686 | freePrivateStorageBytes: number; | |
| 683 | − | /** Days of audit log, the same on every plan. */ | |
| 687 | + | /** Days of audit log a free workspace keeps. */ | |
| 684 | 688 | auditRetentionDays: number; | |
| 689 | + | /** Days of audit log the g1t plan keeps, and g1t's own and enterprise workspaces; longer by arrangement. */ | |
| 690 | + | planAuditRetentionDays?: number; | |
| 685 | 691 | /** The smallest amount a card is charged when a month closes; less carries over. */ | |
| 686 | 692 | minChargeMicros: number; | |
| 687 | 693 | /** Git operations free for every workspace each month. Past it, the plan pays; a free workspace is slowed down. */ | |
| 783 | 789 | /** What the workspace's agents cost since `since`, broken down. Members only. */ | |
| 784 | 790 | usage(workspace: string, viewer: Viewer, since: string): Promise<Result<Usage>>; | |
| 785 | 791 | /** | |
| 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 | + | /** | |
| 786 | 816 | * Prepays usage ($25 at least) and returns the page to send the person to: | |
| 787 | 817 | * by card with 3-D Secure, or by bank transfer from $1,000. Owners only. | |
| 788 | 818 | * The payment's id comes back to `returnUrl` as `session`. | |
| 953 | 983 | /** One slice of usage: what it was for, what it cost, how many runs. */ | |
| 954 | 984 | export type UsageSlice = { key: string; micros: number; runs: number }; | |
| 955 | 985 | ||
| 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 | + | ||
| 956 | 1007 | /** What a workspace's agents cost over a period. */ | |
| 957 | 1008 | export type Usage = { | |
| 958 | 1009 | since: string; |
| 373 | 373 | before: filter.before ?? null, | |
| 374 | 374 | }), | |
| 375 | 375 | 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), | |
| 376 | 379 | checkout: (actor, workspace, amountCents, returnUrl, method = "card") => | |
| 377 | 380 | call("checkout", { actor, workspace, amountCents, returnUrl, method }), | |
| 378 | 381 | confirm: (workspace, viewer, session) => call("confirm", { workspace, viewer, session }), |
| 144 | 144 | session: string; | |
| 145 | 145 | /** For `g1t`: the tier the run was routed to. */ | |
| 146 | 146 | tier?: "small" | "large" | null; | |
| 147 | + | /** The person the run is for, by username. Null when nobody asked; never `g1t`. */ | |
| 148 | + | requestedBy: string | null; | |
| 147 | 149 | baseUrl: string | null; | |
| 148 | 150 | apiKey: string | null; | |
| 149 | 151 | authHeader: string | null; | |
| 187 | 189 | hostedOpen: boolean; | |
| 188 | 190 | /** The tier the run is routed to on g1t's hosted models, for the gateway's logs. */ | |
| 189 | 191 | tier?: "small" | "large" | null; | |
| 192 | + | /** The person the run is for, by username, so its tokens show under them. */ | |
| 193 | + | requestedBy?: string | null; | |
| 190 | 194 | }): Promise<Result<ModelSession>>; | |
| 191 | 195 | routes(workspace: string, viewer: Viewer): Promise<Result<ModelRoute[]>>; | |
| 192 | 196 | setRoutes(actor: User, workspace: string, routes: ModelRoute[]): Promise<Result<ModelRoute[]>>; |
| 40 | 40 | pub updated_at: String, | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 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 | + | ||
| 43 | 56 | impl Actions { | |
| 44 | 57 | /// The workflow files of `path` as of `git_ref` (the default branch | |
| 45 | 58 | /// when absent). | |
| 146 | 159 | for row in existing.iter().filter(|row| !kept.contains(&row.path)) { | |
| 147 | 160 | statements.push( | |
| 148 | 161 | 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()])?, | |
| 151 | 164 | ); | |
| 152 | 165 | } | |
| 153 | 166 | statements.push( | |
| 159 | 172 | Ok(()) | |
| 160 | 173 | } | |
| 161 | 174 | ||
| 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 | + | ||
| 162 | 196 | pub async fn synced(&self, repo_id: &str) -> Result<bool> { | |
| 163 | 197 | Ok(self | |
| 164 | 198 | .db | |
| 198 | 232 | .ok_or_else(|| worker::Error::RustError("the workflow was not recorded".into())) | |
| 199 | 233 | } | |
| 200 | 234 | } | |
| 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 | + | } |
| 347 | 347 | } | |
| 348 | 348 | let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone()); | |
| 349 | 349 | 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 | + | } | |
| 350 | 356 | let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else { | |
| 351 | 357 | continue; | |
| 352 | 358 | }; |
| 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; |
| 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); |
| 56 | 56 | #[serde(default)] | |
| 57 | 57 | issue_cap_micros: Option<i64>, | |
| 58 | 58 | #[serde(default)] | |
| 59 | + | audit_retention_days: Option<u32>, | |
| 60 | + | #[serde(default)] | |
| 59 | 61 | hold: Option<String>, | |
| 60 | 62 | } | |
| 61 | 63 | ||
| 69 | 71 | max_concurrent_agents: self.max_concurrent_agents, | |
| 70 | 72 | run_cap_micros: self.run_cap_micros, | |
| 71 | 73 | issue_cap_micros: self.issue_cap_micros, | |
| 74 | + | audit_retention_days: self.audit_retention_days, | |
| 72 | 75 | hold: self.hold.clone().filter(|h| !h.trim().is_empty()), | |
| 73 | 76 | } | |
| 74 | 77 | } | |
| 505 | 508 | if a.allowances.max_concurrent_agents.is_some_and(|n| n == 0 || n > 1_000) { | |
| 506 | 509 | return Ok(Outcome::fail(FailureCode::Invalid, "Agents at once is between 1 and 1,000.")); | |
| 507 | 510 | } | |
| 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 | + | } | |
| 508 | 514 | let Some(account) = self.find_account(&a.id).await? else { | |
| 509 | 515 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 510 | 516 | }; | |
| 514 | 520 | self.db | |
| 515 | 521 | .prepare( | |
| 516 | 522 | "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) | |
| 519 | 526 | 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", | |
| 521 | 529 | ) | |
| 522 | 530 | .bind(&[ | |
| 523 | 531 | account.id.as_str().into(), | |
| 532 | 540 | opt(a.allowances.run_cap_micros), | |
| 533 | 541 | optional(a.allowances.hold.as_deref().map(str::trim).filter(|h| !h.is_empty())), | |
| 534 | 542 | opt(a.allowances.issue_cap_micros), | |
| 543 | + | a.allowances.audit_retention_days.map_or(JsValue::NULL, JsValue::from), | |
| 535 | 544 | ])? | |
| 536 | 545 | .run() | |
| 537 | 546 | .await?; | |
| 713 | 722 | if let Some(m) = a.issue_cap_micros { | |
| 714 | 723 | parts.push(format!("issue cap {}", crate::features::dollars(m))); | |
| 715 | 724 | } | |
| 725 | + | if let Some(days) = a.audit_retention_days { | |
| 726 | + | parts.push(format!("audit log {days} days")); | |
| 727 | + | } | |
| 716 | 728 | if let Some(hold) = &a.hold { | |
| 717 | 729 | parts.push(format!("hold: {hold}")); | |
| 718 | 730 | } | |
| 764 | 776 | max_concurrent_agents: Some(4), | |
| 765 | 777 | run_cap_micros: Some(3_000_000), | |
| 766 | 778 | issue_cap_micros: None, | |
| 779 | + | audit_retention_days: Some(365), | |
| 767 | 780 | hold: Some("mining".into()), | |
| 768 | 781 | }; | |
| 769 | 782 | assert_eq!( | |
| 770 | 783 | 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" | |
| 772 | 785 | ); | |
| 773 | 786 | } | |
| 774 | 787 |
| 506 | 506 | prepaid_micros: limit.prepaid_micros, | |
| 507 | 507 | included_micros: if has_plan { self.plans.plan_included_micros } else { 0 }, | |
| 508 | 508 | 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(), | |
| 510 | 511 | free_private_storage_bytes: self.plans.free_storage_bytes, | |
| 511 | 512 | private_storage_bytes: stored, | |
| 512 | 513 | oss_paid_micros: oss, |
| 59 | 59 | /// free for every workspace. Past it, the plan pays at cost plus the | |
| 60 | 60 | /// margin; a free workspace's pushes to private repositories stop. | |
| 61 | 61 | 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, | |
| 63 | 67 | pub audit_days: u32, | |
| 68 | + | pub audit_max_days: u32, | |
| 64 | 69 | /// `RUN_CAP_MICROS` and `ISSUE_CAP_MICROS`: one run's spend cap, and | |
| 65 | 70 | /// agents' spend on one issue in all. | |
| 66 | 71 | pub run_cap_micros: i64, | |
| 93 | 98 | trial_monthly_pool_micros: 100_000_000, | |
| 94 | 99 | min_charge_micros: 5_000_000, | |
| 95 | 100 | free_storage_bytes: 1_000_000_000, | |
| 101 | + | free_audit_days: 7, | |
| 96 | 102 | audit_days: 90, | |
| 103 | + | audit_max_days: 400, | |
| 97 | 104 | run_cap_micros: g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS, | |
| 98 | 105 | issue_cap_micros: 10_000_000, | |
| 99 | 106 | paid_start_micros: 100_000_000, | |
| 120 | 127 | trial_monthly_pool_micros: number("TRIAL_MONTHLY_POOL_MICROS", d.trial_monthly_pool_micros), | |
| 121 | 128 | min_charge_micros: number("MIN_CHARGE_MICROS", d.min_charge_micros), | |
| 122 | 129 | 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, | |
| 124 | 133 | run_cap_micros: number("RUN_CAP_MICROS", d.run_cap_micros), | |
| 125 | 134 | issue_cap_micros: number("ISSUE_CAP_MICROS", d.issue_cap_micros), | |
| 126 | 135 | paid_start_micros: number("LIMIT_PAID_START_MICROS", d.paid_start_micros), | |
| 760 | 769 | assert_eq!(c.trial_monthly_pool_micros, 100_000_000); | |
| 761 | 770 | assert_eq!(c.min_charge_micros, 5_000_000); | |
| 762 | 771 | assert_eq!(c.free_storage_bytes, 1_000_000_000); | |
| 772 | + | assert_eq!(c.free_audit_days, 7); | |
| 763 | 773 | assert_eq!(c.audit_days, 90); | |
| 774 | + | assert_eq!(c.audit_max_days, 400); | |
| 764 | 775 | assert_eq!(c.run_cap_micros, g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS); | |
| 765 | 776 | assert_eq!(c.issue_cap_micros, 10_000_000); | |
| 766 | 777 | assert_eq!(c.paid_start_micros, 100_000_000); |
| 420 | 420 | oss_pool_micros: self.plans.oss_pool_micros, | |
| 421 | 421 | oss_repo_micros: self.plans.oss_repo_micros, | |
| 422 | 422 | 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, | |
| 424 | 425 | min_charge_micros: self.plans.min_charge_micros, | |
| 425 | 426 | git_operations_included: self.plans.git_included, | |
| 426 | 427 | paid_start_ceiling_micros: self.plans.paid_start_micros, |
| 37 | 37 | mod keeper; | |
| 38 | 38 | mod limits; | |
| 39 | 39 | mod rename; | |
| 40 | + | mod retention; | |
| 40 | 41 | mod stripe; | |
| 41 | 42 | mod stripe_sync; | |
| 43 | + | mod tokens; | |
| 42 | 44 | ||
| 43 | 45 | use g1t_contracts::billing::*; | |
| 44 | 46 | use g1t_contracts::time::rfc3339; | |
| 1105 | 1107 | "account" => reply(&billing.account(args(body)?).await?), | |
| 1106 | 1108 | "ledger" => reply(&billing.ledger(args(body)?).await?), | |
| 1107 | 1109 | "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?), | |
| 1108 | 1112 | "checkout" => reply(&billing.checkout(args(body)?).await?), | |
| 1109 | 1113 | "confirm" => reply(&billing.confirm(args(body)?).await?), | |
| 1110 | 1114 | "can_start" => reply(&billing.can_start(args(body)?).await?), | |
| 1153 | 1157 | "admin_credit" => reply(&billing.admin_credit(args(body)?).await?), | |
| 1154 | 1158 | "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?), | |
| 1155 | 1159 | "entitlements" => reply(&billing.entitlements(args(body)?).await?), | |
| 1160 | + | "audit_retention" => reply(&billing.audit_retention(args(body)?).await?), | |
| 1156 | 1161 | "reserve" => reply(&billing.reserve(args(body)?).await?), | |
| 1157 | 1162 | "settle" => reply(&billing.settle_reservation(args(body)?).await?), | |
| 1158 | 1163 | "card_check" => reply(&billing.card_check(args(body)?).await?), |
| 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 | + | } |
| 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 | + | } |
| 54 | 54 | "PLAN_INCLUDED_MICROS": "10000000", | |
| 55 | 55 | // 1 GB of private storage free for every workspace: past it, the plan | |
| 56 | 56 | // 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. | |
| 58 | 58 | "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", | |
| 59 | 64 | "AUDIT_RETENTION_DAYS": "90", | |
| 65 | + | "AUDIT_MAX_DAYS": "400", | |
| 60 | 66 | // The trial (src/credits.rs, src/cards.rs): $5 of usage once per new | |
| 61 | 67 | // workspace, granted after a card check (one per card), out of a pool | |
| 62 | 68 | // for everyone ($100) that resets each calendar month (UTC). Either at |
| 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 | + | ); |
| 8 | 8 | AuditEntry, AuditPage, AuditVisibility, ListAuditArgs, MAX_AUDIT_PAGE, NewAuditEntry, | |
| 9 | 9 | RecordAuditArgs, | |
| 10 | 10 | }; | |
| 11 | + | use g1t_contracts::billing::{AuditRetention, AuditRetentionArgs}; | |
| 11 | 12 | use g1t_contracts::events::{Event, WorkspaceRenamed}; | |
| 12 | 13 | use g1t_contracts::new_id; | |
| 13 | 14 | use g1t_contracts::time::rfc3339; | |
| 14 | 15 | use g1t_kit::now_ms; | |
| 15 | 16 | use serde::Deserialize; | |
| 16 | 17 | use worker::wasm_bindgen::JsValue; | |
| 17 | − | use worker::{D1Database, Result}; | |
| 18 | + | use worker::{D1Database, Fetcher, Result}; | |
| 18 | 19 | ||
| 19 | 20 | const DEFAULT_PAGE: u32 = 100; | |
| 20 | 21 | /// More than one request ever records. | |
| 277 | 278 | Ok(AuditPage { entries, next }) | |
| 278 | 279 | } | |
| 279 | 280 | ||
| 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; | |
| 283 | 294 | /// Rows removed per statement, so one purge never runs long. | |
| 284 | 295 | const PURGE_BATCH: u32 = 5_000; | |
| 285 | 296 | ||
| 288 | 299 | g1t_contracts::time::rfc3339(now_ms.saturating_sub(u64::from(keep_days) * 24 * 60 * 60 * 1000)) | |
| 289 | 300 | } | |
| 290 | 301 | ||
| 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 | + | ||
| 291 | 321 | /// Removes entries older than every plan keeps, a batch at a time, up to | |
| 292 | 322 | /// `rounds` batches. Returns how many went. | |
| 293 | 323 | pub async fn purge(db: &D1Database, before: &str, rounds: u32) -> Result<u32> { | |
| 310 | 340 | Ok(removed) | |
| 311 | 341 | } | |
| 312 | 342 | ||
| 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 | + | ||
| 313 | 459 | /// Moves a renamed workspace's rows to its new slug. | |
| 314 | 460 | pub async fn follow_renames(db: &D1Database, events: &[Event]) -> Result<()> { | |
| 315 | 461 | for event in events | |
| 352 | 498 | // 2026-10-05T00:00:00Z, a year back. | |
| 353 | 499 | let now = 1_791_158_400_000; | |
| 354 | 500 | 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); | |
| 356 | 534 | } | |
| 357 | 535 | ||
| 358 | 536 | fn args() -> ListAuditArgs { |
| 209 | 209 | Ok(()) | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 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. | |
| 214 | 217 | #[event(scheduled)] | |
| 215 | 218 | 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); | |
| 221 | 228 | 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 | + | } | |
| 224 | 234 | Ok(_) => {} | |
| 225 | 235 | Err(error) => worker::console_error!("could not remove old audit entries: {error}"), | |
| 226 | 236 | } | |
| 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 | + | } | |
| 227 | 255 | } |
| 38 | 38 | ], | |
| 39 | 39 | "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1 }] | |
| 40 | 40 | }, | |
| 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. | |
| 44 | 48 | "triggers": { "crons": ["41 3 * * *"] }, | |
| 45 | − | "vars": { "AUDIT_KEEP_DAYS": "90" }, | |
| 49 | + | "vars": { "AUDIT_MAX_DAYS": "400", "AUDIT_MIN_DAYS": "7" }, | |
| 46 | 50 | "observability": { "enabled": true } | |
| 47 | 51 | } |
| 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; |
| 123 | 123 | out | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 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 | + | ||
| 126 | 133 | /// A model session's public id: the start of its token's hash. | |
| 127 | 134 | fn session_id(token_hash: &str) -> String { | |
| 128 | 135 | format!("ms_{}", &token_hash[..token_hash.len().min(24)]) | |
| 139 | 146 | model: Option<String>, | |
| 140 | 147 | #[serde(default)] | |
| 141 | 148 | tier: Option<String>, | |
| 149 | + | #[serde(default)] | |
| 150 | + | requested_by: Option<String>, | |
| 142 | 151 | } | |
| 143 | 152 | ||
| 144 | 153 | #[derive(Deserialize)] | |
| 1229 | 1238 | .bind(&[rfc3339(now).into()])?, | |
| 1230 | 1239 | self.db | |
| 1231 | 1240 | .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1234 | 1243 | ) | |
| 1235 | 1244 | .bind(&[ | |
| 1236 | 1245 | crypto::sha256_hex(&token).into(), | |
| 1247 | 1256 | .as_deref() | |
| 1248 | 1257 | .filter(|tier| connection.is_none() && matches!(*tier, "small" | "large")), | |
| 1249 | 1258 | ), | |
| 1259 | + | optional(requester(a.requested_by.as_deref()).as_deref()), | |
| 1250 | 1260 | ])?, | |
| 1251 | 1261 | ]) | |
| 1252 | 1262 | .await?; | |
| 1282 | 1292 | task: session.task, | |
| 1283 | 1293 | session: session_id(&session.token_hash), | |
| 1284 | 1294 | tier: session.tier, | |
| 1295 | + | requested_by: session.requested_by, | |
| 1285 | 1296 | base_url: None, | |
| 1286 | 1297 | api_key: None, | |
| 1287 | 1298 | auth_header: None, | |
| 1490 | 1501 | ||
| 1491 | 1502 | #[cfg(test)] | |
| 1492 | 1503 | 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 | + | } | |
| 1494 | 1514 | ||
| 1495 | 1515 | #[test] | |
| 1496 | 1516 | fn only_token_hashes_are_closed() { |
| 11 | 11 | * the run ends (the runner closes its session then, and lookups are kept | |
| 12 | 12 | * only seconds), and nothing of the workspace's. | |
| 13 | 13 | * | |
| 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. | |
| 15 | 17 | */ | |
| 16 | − | import { type ModelUpstream, type ServiceBinding, integrationsClient } from "@g1t/contracts"; | |
| 18 | + | import { type ModelUpstream, type ServiceBinding, billingClient, integrationsClient } from "@g1t/contracts"; | |
| 17 | 19 | ||
| 18 | 20 | import { type AnthropicRequest, StreamTranslator, errorFromChat, estimateTokens, fromChat, toChat } from "./openai"; | |
| 21 | + | import { isAnswer, tokenReport } from "./report"; | |
| 19 | 22 | import { type HostedRouting, presentedToken, upstreamRequest } from "./route"; | |
| 23 | + | import { measure } from "./usage"; | |
| 20 | 24 | ||
| 21 | 25 | interface Env extends HostedRouting { | |
| 22 | 26 | INTEGRATIONS: ServiceBinding; | |
| 27 | + | BILLING: ServiceBinding; | |
| 23 | 28 | } | |
| 24 | 29 | ||
| 25 | 30 | /** | |
| 48 | 53 | ); | |
| 49 | 54 | } | |
| 50 | 55 | ||
| 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 | + | ||
| 51 | 72 | /** Sends an Anthropic request to a provider that speaks OpenAI's API. */ | |
| 52 | 73 | async function viaChat(upstream: ModelUpstream, path: string, request: Request): Promise<Response> { | |
| 53 | 74 | const body = (await request.json()) as AnthropicRequest; | |
| 95 | 116 | } | |
| 96 | 117 | ||
| 97 | 118 | export default { | |
| 98 | − | async fetch(request: Request, env: Env): Promise<Response> { | |
| 119 | + | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 99 | 120 | const url = new URL(request.url); | |
| 100 | 121 | if (url.pathname === "/" || url.pathname === "") { | |
| 101 | 122 | return new Response("g1t's model proxy, for g1t's sandboxes. See https://docs.g1t.sh/guides/models/\n"); | |
| 107 | 128 | if (!upstream) return refuse(401, "This run's model token has expired, or its model connection was removed."); | |
| 108 | 129 | ||
| 109 | 130 | 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)); | |
| 111 | 134 | ||
| 112 | 135 | const { url: target, headers } = upstreamRequest(upstream, env, path, request.headers); | |
| 113 | 136 | // A route that names a model gets it for every request of the run, | |
| 118 | 141 | body = JSON.stringify({ ...parsed, model: upstream.model }); | |
| 119 | 142 | headers.delete("content-length"); | |
| 120 | 143 | } | |
| 121 | − | return fetch(target, { method: request.method, headers, body }); | |
| 144 | + | return answer(await fetch(target, { method: request.method, headers, body })); | |
| 122 | 145 | }, | |
| 123 | 146 | } satisfies ExportedHandler<Env>; |
| 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 | + | }); |
| 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 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.