| 1 | /** |
| 2 | * Spend's arithmetic and wording (routes/workspace/spend.tsx and the top |
| 3 | * bar's spend pill): which days a period covers, what the price book says |
| 4 | * about pricing, a day-by-day series, a task's receipt from its session |
| 5 | * tree, and what each budget does when it is reached. Pure, with type-only |
| 6 | * imports, so it is tested on its own. Every number comes from a service; |
| 7 | * nothing here invents one. |
| 8 | */ |
| 9 | import type { AgentSession, PriceBook, SpendPeriod, SpendSlice, UsageDay } from "@g1t/contracts"; |
| 10 | |
| 11 | /** Whose spend: your own (what agents did for you), or the whole workspace's. */ |
| 12 | export type SpendScope = "me" | "workspace"; |
| 13 | |
| 14 | export const SPEND_PERIODS: { key: SpendPeriod; label: string; short: string }[] = [ |
| 15 | { key: "month", label: "This month", short: "This month" }, |
| 16 | { key: "last_month", label: "Last month", short: "Last month" }, |
| 17 | { key: "30d", label: "Last 30 days", short: "30 days" }, |
| 18 | { key: "7d", label: "Last 7 days", short: "7 days" }, |
| 19 | ]; |
| 20 | |
| 21 | export function readPeriod(raw: string | null | undefined): SpendPeriod { |
| 22 | return SPEND_PERIODS.some((p) => p.key === raw) ? (raw as SpendPeriod) : "month"; |
| 23 | } |
| 24 | |
| 25 | export function periodLabel(period: SpendPeriod): string { |
| 26 | return SPEND_PERIODS.find((p) => p.key === period)?.label ?? "This month"; |
| 27 | } |
| 28 | |
| 29 | /** Whose view a request asks for: the workspace's only for someone who may see it. */ |
| 30 | export function readScope(raw: string | null | undefined, mayWorkspace: boolean): SpendScope { |
| 31 | if (!mayWorkspace) return "me"; |
| 32 | return raw === "me" ? "me" : "workspace"; |
| 33 | } |
| 34 | |
| 35 | const iso = (at: Date) => at.toISOString().slice(0, 10); |
| 36 | |
| 37 | /** |
| 38 | * The days a period covers in UTC, both ends included, as the agents |
| 39 | * service counts them (services/agents/src/budget.ts `spendSpan`). |
| 40 | */ |
| 41 | export function spanFor(period: SpendPeriod, now: Date): { from: string; until: string } { |
| 42 | const y = now.getUTCFullYear(); |
| 43 | const m = now.getUTCMonth(); |
| 44 | const d = now.getUTCDate(); |
| 45 | switch (period) { |
| 46 | case "last_month": |
| 47 | return { from: iso(new Date(Date.UTC(y, m - 1, 1))), until: iso(new Date(Date.UTC(y, m, 0))) }; |
| 48 | case "7d": |
| 49 | return { from: iso(new Date(Date.UTC(y, m, d - 6))), until: iso(now) }; |
| 50 | case "30d": |
| 51 | return { from: iso(new Date(Date.UTC(y, m, d - 29))), until: iso(now) }; |
| 52 | default: |
| 53 | return { from: iso(new Date(Date.UTC(y, m, 1))), until: iso(now) }; |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /** Every day from `from` to `until`, with what was spent on it; days with none are 0. */ |
| 58 | export function daySeries(from: string, until: string, spent: { day: string; micros: number }[]): { day: string; micros: number }[] { |
| 59 | const by = new Map<string, number>(); |
| 60 | for (const s of spent) by.set(s.day, (by.get(s.day) ?? 0) + s.micros); |
| 61 | const out: { day: string; micros: number }[] = []; |
| 62 | const at = new Date(`${from}T00:00:00Z`); |
| 63 | const end = new Date(`${until}T00:00:00Z`); |
| 64 | if (Number.isNaN(at.getTime()) || Number.isNaN(end.getTime())) return out; |
| 65 | for (let n = 0; at <= end && n < 400; n++) { |
| 66 | const key = iso(at); |
| 67 | out.push({ day: key, micros: by.get(key) ?? 0 }); |
| 68 | at.setUTCDate(at.getUTCDate() + 1); |
| 69 | } |
| 70 | return out; |
| 71 | } |
| 72 | |
| 73 | /** Usage's days (one row per product a day) as one total a day. */ |
| 74 | export function usageByDay(days: UsageDay[]): { day: string; micros: number }[] { |
| 75 | return days.map((d) => ({ day: d.day, micros: d.micros })); |
| 76 | } |
| 77 | |
| 78 | /** What the price book says about pricing, for the page's pricing card. */ |
| 79 | export type Pricing = { |
| 80 | /** What models are marked up, in percent: 0 is the provider's price. */ |
| 81 | modelMarkupPercent: number; |
| 82 | /** What g1t's own metered work is marked up: one figure, or the range across meters. */ |
| 83 | markup: { min: number; max: number } | null; |
| 84 | /** g1t's agent rate per million tokens, on g1t's models and on your own key, in force now. Null when the book has none. */ |
| 85 | agentRateMicros: number | null; |
| 86 | agentRateOwnMicros: number | null; |
| 87 | /** The agent rate still to come, when it is $0 now and a dated version waits: its price and the day it starts. */ |
| 88 | agentRateComing: { micros: number; from: string } | null; |
| 89 | }; |
| 90 | |
| 91 | /** |
| 92 | * Meters that are not something g1t runs at a cost: models (at the |
| 93 | * provider's price), AI Gateway, card fees (Stripe's), flat activations, |
| 94 | * and per-token rates with their weights. |
| 95 | */ |
| 96 | const NOT_RUN = (meter: string) => |
| 97 | meter === "agent_models" || meter === "gateway_models" || meter.startsWith("agent_token") || meter.startsWith("card_fee") || meter === "security_activation"; |
| 98 | |
| 99 | export function pricingOf(book: PriceBook): Pricing { |
| 100 | const run = book.prices.filter((p) => !NOT_RUN(p.meter) && p.costMicros > 0); |
| 101 | const markups = run.map((p) => p.markupPercent); |
| 102 | const rate = (meter: string) => { |
| 103 | const price = book.prices.find((p) => p.meter === meter); |
| 104 | return price ? price.priceMicros : null; |
| 105 | }; |
| 106 | const coming = book.changes.find((c) => c.meter === "agent_tokens" && c.effectiveAt); |
| 107 | return { |
| 108 | modelMarkupPercent: book.modelMarginPercent, |
| 109 | markup: markups.length ? { min: Math.min(...markups), max: Math.max(...markups) } : null, |
| 110 | agentRateMicros: rate("agent_tokens"), |
| 111 | agentRateOwnMicros: rate("agent_tokens_own"), |
| 112 | agentRateComing: coming ? { micros: coming.newCostMicros, from: coming.effectiveAt!.slice(0, 10) } : null, |
| 113 | }; |
| 114 | } |
| 115 | |
| 116 | /** "$0.25 per million tokens", or "$0.25 per million tokens from 2026-10-22" while it waits for its date. */ |
| 117 | export function agentRateLabel(pricing: Pick<Pricing, "agentRateMicros" | "agentRateComing">, money: (micros: number) => string): string { |
| 118 | if (pricing.agentRateMicros && pricing.agentRateMicros > 0) return `${money(pricing.agentRateMicros)} per million tokens`; |
| 119 | if (pricing.agentRateComing) return `${money(pricing.agentRateComing.micros)} per million tokens from ${pricing.agentRateComing.from}`; |
| 120 | return "Per million tokens"; |
| 121 | } |
| 122 | |
| 123 | /** "20%", or "15–20%" when meters differ. */ |
| 124 | export function markupLabel(markup: { min: number; max: number }): string { |
| 125 | return markup.min === markup.max ? `${markup.min}%` : `${markup.min}–${markup.max}%`; |
| 126 | } |
| 127 | |
| 128 | /** One line of a task's receipt: one session of its tree. */ |
| 129 | export type ReceiptLine = { |
| 130 | session: AgentSession; |
| 131 | depth: number; |
| 132 | /** What this session's own steps were charged (a root's charge includes its tree's). */ |
| 133 | ownMicros: number; |
| 134 | }; |
| 135 | |
| 136 | export type Receipt = { |
| 137 | root: AgentSession; |
| 138 | lines: ReceiptLine[]; |
| 139 | /** The whole tree, as the root's charge counts it. */ |
| 140 | chargedMicros: number; |
| 141 | /** The model answers at the provider's price, every session's together. */ |
| 142 | providerMicros: number; |
| 143 | inputTokens: number; |
| 144 | outputTokens: number; |
| 145 | steps: number; |
| 146 | toolCalls: number; |
| 147 | }; |
| 148 | |
| 149 | /** |
| 150 | * A task's receipt from its session tree (root first, as the agents |
| 151 | * service gives it): each session's own charge, children under their |
| 152 | * parents, and the totals. A child's charge is added to the root as it is |
| 153 | * spent, so the root's own share is its charge less its children's. |
| 154 | */ |
| 155 | export function receiptOf(tree: AgentSession[], rootId: string): Receipt | null { |
| 156 | const root = tree.find((s) => s.id === rootId) ?? tree.find((s) => s.parent_id == null); |
| 157 | if (!root) return null; |
| 158 | const children = new Map<string, AgentSession[]>(); |
| 159 | for (const s of tree) { |
| 160 | if (s.id === root.id || !s.parent_id) continue; |
| 161 | children.set(s.parent_id, [...(children.get(s.parent_id) ?? []), s]); |
| 162 | } |
| 163 | const lines: ReceiptLine[] = []; |
| 164 | const seen = new Set<string>(); |
| 165 | const walk = (s: AgentSession, depth: number) => { |
| 166 | if (seen.has(s.id)) return; |
| 167 | seen.add(s.id); |
| 168 | lines.push({ session: s, depth, ownMicros: s.charged_micros }); |
| 169 | for (const kid of [...(children.get(s.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at))) walk(kid, depth + 1); |
| 170 | }; |
| 171 | walk(root, 0); |
| 172 | const others = lines.slice(1).reduce((n, l) => n + l.ownMicros, 0); |
| 173 | lines[0]!.ownMicros = Math.max(0, root.charged_micros - others); |
| 174 | const sum = (pick: (s: AgentSession) => number) => lines.reduce((n, l) => n + (pick(l.session) || 0), 0); |
| 175 | return { |
| 176 | root, |
| 177 | lines, |
| 178 | chargedMicros: root.charged_micros, |
| 179 | providerMicros: sum((s) => s.cost_micros ?? 0), |
| 180 | inputTokens: sum((s) => s.input_tokens), |
| 181 | outputTokens: sum((s) => s.output_tokens), |
| 182 | steps: sum((s) => s.steps), |
| 183 | toolCalls: sum((s) => s.tool_calls), |
| 184 | }; |
| 185 | } |
| 186 | |
| 187 | /** A count of tokens for a receipt: "412K", "1.2M", "830". */ |
| 188 | export function tokenCount(n: number): string { |
| 189 | if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`; |
| 190 | if (n >= 10_000) return `${Math.round(n / 1000).toLocaleString("en-US")}K`; |
| 191 | if (n >= 1000) return `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; |
| 192 | return n.toLocaleString("en-US"); |
| 193 | } |
| 194 | |
| 195 | /** How far through a budget: 0 to 1 (more when over), null with none. */ |
| 196 | export function shareOfBudget(spent: number, budget: number | null | undefined): number | null { |
| 197 | return budget != null && budget > 0 ? Math.max(0, spent) / budget : null; |
| 198 | } |
| 199 | |
| 200 | /** "62%" of a budget, for a pill or a row; empty with none. */ |
| 201 | export function percentLabel(spent: number, budget: number | null | undefined): string { |
| 202 | const share = shareOfBudget(spent, budget); |
| 203 | return share == null ? "" : `${Math.round(share * 100)}%`; |
| 204 | } |
| 205 | |
| 206 | /** The slices a person's own view leaves out: the "who asked" one is only ever them. */ |
| 207 | export function withoutSelf(slices: SpendSlice[], username: string): SpendSlice[] { |
| 208 | return slices.filter((s) => s.key !== username); |
| 209 | } |
| 210 | |
| 211 | /** What a level of budget does when it is reached, as the page says it. */ |
| 212 | export const AT_LIMIT = { |
| 213 | workspace: (pauses: boolean) => |
| 214 | pauses |
| 215 | ? "New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. Work already running finishes." |
| 216 | : "Owners are alerted, and work goes on; g1t's own ceiling still applies.", |
| 217 | agents: "No agent takes new work until the 1st, or until an owner raises it.", |
| 218 | person: "Agents take no new work for that person until the 1st; they say so where they were asked.", |
| 219 | agent: "That agent takes no new work until the 1st, or the next day for a daily cap.", |
| 220 | session: "The session stops at Needs approval, and an owner decides whether it goes on.", |
| 221 | } as const; |