| 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. Null when the book has none. */ |
| 85 | agentRateMicros: number | null; |
| 86 | agentRateOwnMicros: number | null; |
| 87 | }; |
| 88 | |
| 89 | const AGENT_METERS = new Set(["agent_models", "agent_tokens", "agent_tokens_own"]); |
| 90 | |
| 91 | export function pricingOf(book: PriceBook): Pricing { |
| 92 | const run = book.prices.filter((p) => !AGENT_METERS.has(p.meter) && p.costMicros > 0); |
| 93 | const markups = run.map((p) => p.markupPercent); |
| 94 | const rate = (meter: string) => { |
| 95 | const price = book.prices.find((p) => p.meter === meter); |
| 96 | return price ? price.priceMicros : null; |
| 97 | }; |
| 98 | return { |
| 99 | modelMarkupPercent: book.modelMarginPercent, |
| 100 | markup: markups.length ? { min: Math.min(...markups), max: Math.max(...markups) } : null, |
| 101 | agentRateMicros: rate("agent_tokens"), |
| 102 | agentRateOwnMicros: rate("agent_tokens_own"), |
| 103 | }; |
| 104 | } |
| 105 | |
| 106 | /** "20%", or "15–20%" when meters differ. */ |
| 107 | export function markupLabel(markup: { min: number; max: number }): string { |
| 108 | return markup.min === markup.max ? `${markup.min}%` : `${markup.min}–${markup.max}%`; |
| 109 | } |
| 110 | |
| 111 | /** One line of a task's receipt: one session of its tree. */ |
| 112 | export type ReceiptLine = { |
| 113 | session: AgentSession; |
| 114 | depth: number; |
| 115 | /** What this session's own steps were charged (a root's charge includes its tree's). */ |
| 116 | ownMicros: number; |
| 117 | }; |
| 118 | |
| 119 | export type Receipt = { |
| 120 | root: AgentSession; |
| 121 | lines: ReceiptLine[]; |
| 122 | /** The whole tree, as the root's charge counts it. */ |
| 123 | chargedMicros: number; |
| 124 | /** The model answers at the provider's price, every session's together. */ |
| 125 | providerMicros: number; |
| 126 | inputTokens: number; |
| 127 | outputTokens: number; |
| 128 | steps: number; |
| 129 | toolCalls: number; |
| 130 | }; |
| 131 | |
| 132 | /** |
| 133 | * A task's receipt from its session tree (root first, as the agents |
| 134 | * service gives it): each session's own charge, children under their |
| 135 | * parents, and the totals. A child's charge is added to the root as it is |
| 136 | * spent, so the root's own share is its charge less its children's. |
| 137 | */ |
| 138 | export function receiptOf(tree: AgentSession[], rootId: string): Receipt | null { |
| 139 | const root = tree.find((s) => s.id === rootId) ?? tree.find((s) => s.parent_id == null); |
| 140 | if (!root) return null; |
| 141 | const children = new Map<string, AgentSession[]>(); |
| 142 | for (const s of tree) { |
| 143 | if (s.id === root.id || !s.parent_id) continue; |
| 144 | children.set(s.parent_id, [...(children.get(s.parent_id) ?? []), s]); |
| 145 | } |
| 146 | const lines: ReceiptLine[] = []; |
| 147 | const seen = new Set<string>(); |
| 148 | const walk = (s: AgentSession, depth: number) => { |
| 149 | if (seen.has(s.id)) return; |
| 150 | seen.add(s.id); |
| 151 | lines.push({ session: s, depth, ownMicros: s.charged_micros }); |
| 152 | for (const kid of [...(children.get(s.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at))) walk(kid, depth + 1); |
| 153 | }; |
| 154 | walk(root, 0); |
| 155 | const others = lines.slice(1).reduce((n, l) => n + l.ownMicros, 0); |
| 156 | lines[0]!.ownMicros = Math.max(0, root.charged_micros - others); |
| 157 | const sum = (pick: (s: AgentSession) => number) => lines.reduce((n, l) => n + (pick(l.session) || 0), 0); |
| 158 | return { |
| 159 | root, |
| 160 | lines, |
| 161 | chargedMicros: root.charged_micros, |
| 162 | providerMicros: sum((s) => s.cost_micros ?? 0), |
| 163 | inputTokens: sum((s) => s.input_tokens), |
| 164 | outputTokens: sum((s) => s.output_tokens), |
| 165 | steps: sum((s) => s.steps), |
| 166 | toolCalls: sum((s) => s.tool_calls), |
| 167 | }; |
| 168 | } |
| 169 | |
| 170 | /** A count of tokens for a receipt: "412K", "1.2M", "830". */ |
| 171 | export function tokenCount(n: number): string { |
| 172 | if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`; |
| 173 | if (n >= 10_000) return `${Math.round(n / 1000).toLocaleString("en-US")}K`; |
| 174 | if (n >= 1000) return `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; |
| 175 | return n.toLocaleString("en-US"); |
| 176 | } |
| 177 | |
| 178 | /** How far through a budget: 0 to 1 (more when over), null with none. */ |
| 179 | export function shareOfBudget(spent: number, budget: number | null | undefined): number | null { |
| 180 | return budget != null && budget > 0 ? Math.max(0, spent) / budget : null; |
| 181 | } |
| 182 | |
| 183 | /** "62%" of a budget, for a pill or a row; empty with none. */ |
| 184 | export function percentLabel(spent: number, budget: number | null | undefined): string { |
| 185 | const share = shareOfBudget(spent, budget); |
| 186 | return share == null ? "" : `${Math.round(share * 100)}%`; |
| 187 | } |
| 188 | |
| 189 | /** The slices a person's own view leaves out: the "who asked" one is only ever them. */ |
| 190 | export function withoutSelf(slices: SpendSlice[], username: string): SpendSlice[] { |
| 191 | return slices.filter((s) => s.key !== username); |
| 192 | } |
| 193 | |
| 194 | /** What a level of budget does when it is reached, as the page says it. */ |
| 195 | export const AT_LIMIT = { |
| 196 | workspace: (pauses: boolean) => |
| 197 | pauses |
| 198 | ? "New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. Work already running finishes." |
| 199 | : "Owners are alerted, and work goes on; g1t's own ceiling still applies.", |
| 200 | agents: "No agent takes new work until the 1st, or until an owner raises it.", |
| 201 | person: "Agents take no new work for that person until the 1st; they say so where they were asked.", |
| 202 | agent: "That agent takes no new work until the 1st, or the next day for a daily cap.", |
| 203 | session: "The session stops at Needs approval, and an owner decides whether it goes on.", |
| 204 | } as const; |