| 1 | /** |
| 2 | * An agent's budget: what it has spent, whether it may reply, and what a |
| 3 | * reply cost. Pure, so it is tested on its own. |
| 4 | * |
| 5 | * Spend is limited at several levels (docs.g1t.sh/guides/agent-budgets/). |
| 6 | * The workspace's own limit and AI credit are billing's, checked by the |
| 7 | * compute gate. The agent's monthly and daily caps, and the budget of the |
| 8 | * person who asked, are checked here, before anything is reserved. |
| 9 | * Spend counts what a reply costs at price: the model at the provider's |
| 10 | * price with billing's model margin, plus g1t's agent rate on every token. |
| 11 | * The ledger (with comped terms and discounts) is billing's; the agent's |
| 12 | * cap is about how much work it does, so it counts the list price. |
| 13 | */ |
| 14 | import type { AgentBudget, AgentStatus } from "@g1t/contracts"; |
| 15 | |
| 16 | import type { TokenPrice } from "../../runner/src/model-env.ts"; |
| 17 | |
| 18 | /** `2026-10`: the month spend is rolled up under, in UTC. */ |
| 19 | export function monthKey(now: Date): string { |
| 20 | return now.toISOString().slice(0, 7); |
| 21 | } |
| 22 | |
| 23 | /** `2026-10-08`: the day spend is rolled up under, in UTC. */ |
| 24 | export function dayKey(now: Date): string { |
| 25 | return now.toISOString().slice(0, 10); |
| 26 | } |
| 27 | |
| 28 | const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; |
| 29 | |
| 30 | export type Spent = { month: number; day: number }; |
| 31 | |
| 32 | export type BudgetBlock = { cap: "month" | "day"; message: string }; |
| 33 | |
| 34 | const capSet = (cap: number | null | undefined): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0; |
| 35 | |
| 36 | /** |
| 37 | * Why the agent may not take another reply on its own budget, as it says |
| 38 | * so in chat, or null when it may. A cap of zero or null is no cap. |
| 39 | */ |
| 40 | export function budgetBlock(budget: AgentBudget, spent: Spent, now: Date): BudgetBlock | null { |
| 41 | if (capSet(budget.monthly_micros) && spent.month >= budget.monthly_micros) { |
| 42 | return { |
| 43 | cap: "month", |
| 44 | message: `I'm out of budget for ${MONTHS[now.getUTCMonth()]}. An owner can raise my monthly limit on my profile.`, |
| 45 | }; |
| 46 | } |
| 47 | if (capSet(budget.daily_micros) && spent.day >= budget.daily_micros) { |
| 48 | return { |
| 49 | cap: "day", |
| 50 | message: "I've used today's budget. I'll be back tomorrow (UTC), or an owner can raise my daily limit on my profile.", |
| 51 | }; |
| 52 | } |
| 53 | return null; |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * The most one reply may spend on models, in millionths of a dollar: the |
| 58 | * lowest of what is left of the agent's month and day, its per-task cap, |
| 59 | * and the plan's per-run cap. Null when nothing caps it. Never below 1, so |
| 60 | * a cap is never read as "none". |
| 61 | */ |
| 62 | export function replyCapMicros(budget: AgentBudget, spent: Spent, planRunCapMicros: number | null): number | null { |
| 63 | const caps: number[] = []; |
| 64 | if (capSet(budget.monthly_micros)) caps.push(budget.monthly_micros - spent.month); |
| 65 | if (capSet(budget.daily_micros)) caps.push(budget.daily_micros - spent.day); |
| 66 | if (capSet(budget.task_micros)) caps.push(budget.task_micros); |
| 67 | if (capSet(planRunCapMicros)) caps.push(planRunCapMicros); |
| 68 | return caps.length ? Math.max(1, Math.floor(Math.min(...caps))) : null; |
| 69 | } |
| 70 | |
| 71 | export type Span = { span: "month" | "last_month" | "7d" | "30d"; from: string; until: string; period: string }; |
| 72 | |
| 73 | /** |
| 74 | * The days a spend breakdown covers, in UTC, both ends included: this |
| 75 | * month to today (anything not asked for), last month whole, or the last |
| 76 | * 7 or 30 days to today. `period` is the month it ends in. |
| 77 | */ |
| 78 | export function spendSpan(asked: unknown, now: Date): Span { |
| 79 | const today = dayKey(now); |
| 80 | const day = (offset: number) => dayKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offset))); |
| 81 | switch (asked) { |
| 82 | case "last_month": { |
| 83 | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)); |
| 84 | const until = dayKey(end); |
| 85 | return { span: "last_month", from: `${until.slice(0, 7)}-01`, until, period: until.slice(0, 7) }; |
| 86 | } |
| 87 | case "7d": |
| 88 | return { span: "7d", from: day(-6), until: today, period: monthKey(now) }; |
| 89 | case "30d": |
| 90 | return { span: "30d", from: day(-29), until: today, period: monthKey(now) }; |
| 91 | default: |
| 92 | return { span: "month", from: `${monthKey(now)}-01`, until: today, period: monthKey(now) }; |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * The budget that applies to one person: their own when an owner set one |
| 98 | * (0 there means none at all), else the workspace's per-person default. |
| 99 | * Null: no budget. |
| 100 | */ |
| 101 | export function personLimit(defaultMicros: number | null | undefined, own: number | null | undefined): number | null { |
| 102 | if (typeof own === "number" && Number.isFinite(own)) return own > 0 ? Math.floor(own) : null; |
| 103 | return capSet(defaultMicros) ? Math.floor(defaultMicros) : null; |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Why work asked for by `username` may not start: what agents spent for |
| 108 | * them this month has reached their budget. Null when it may. |
| 109 | */ |
| 110 | export function personBlock(username: string, limit: number | null, spent: number, now: Date): string | null { |
| 111 | if (!capSet(limit) || spent < limit) return null; |
| 112 | return `@${username} has used their agent budget for ${MONTHS[now.getUTCMonth()]}. An owner can raise it under Workspace → Spend.`; |
| 113 | } |
| 114 | |
| 115 | /** The tokens one answer used, by kind. */ |
| 116 | export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }; |
| 117 | |
| 118 | export function totalTokens(tokens: Tokens): number { |
| 119 | return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite; |
| 120 | } |
| 121 | |
| 122 | /** What `tokens` cost at `price` (dollars per million tokens), in millionths of a dollar. */ |
| 123 | export function costMicros(tokens: Tokens, price: TokenPrice | null): number { |
| 124 | if (!price) return 0; |
| 125 | const micros = tokens.input * price.input + tokens.output * price.output + tokens.cacheRead * price.cacheRead + tokens.cacheWrite * price.cacheWrite; |
| 126 | return Math.ceil(Math.max(0, micros)); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * What a reply counts against the agent's budget: on g1t's models, the |
| 131 | * model's cost with the model margin; on the workspace's own provider, |
| 132 | * nothing for the model (the provider bills the workspace). On both, the |
| 133 | * agent rate (`agent_tokens` or `agent_tokens_own` in the price book, per |
| 134 | * million tokens) on every token. |
| 135 | */ |
| 136 | export function chargedMicros(input: { |
| 137 | costMicros: number; |
| 138 | hosted: boolean; |
| 139 | marginPercent: number; |
| 140 | ratePerMillionMicros: number; |
| 141 | tokens: number; |
| 142 | }): number { |
| 143 | const model = input.hosted ? Math.ceil((input.costMicros * (100 + Math.max(0, input.marginPercent))) / 100) : 0; |
| 144 | const rate = Math.ceil((Math.max(0, input.tokens) * Math.max(0, input.ratePerMillionMicros)) / 1_000_000); |
| 145 | return model + rate; |
| 146 | } |
| 147 | |
| 148 | /** The agent's presence, as the Agents page shows it. */ |
| 149 | export function agentStatus(input: { busyUntil: string | null; now: Date; blocked: boolean }): AgentStatus { |
| 150 | if (input.busyUntil && Date.parse(input.busyUntil) > input.now.getTime()) return "working"; |
| 151 | if (input.blocked) return "out_of_budget"; |
| 152 | return "idle"; |
| 153 | } |