Skip to content
153 linesCodeBlameRaw
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 */
14import type { AgentBudget, AgentStatus } from "@g1t/contracts";
15
16import type { TokenPrice } from "../../runner/src/model-env.ts";
17
18/** `2026-10`: the month spend is rolled up under, in UTC. */
19export 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. */
24export function dayKey(now: Date): string {
25 return now.toISOString().slice(0, 10);
26}
27
28const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
29
30export type Spent = { month: number; day: number };
31
32export type BudgetBlock = { cap: "month" | "day"; message: string };
33
34const 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 */
40export 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 */
62export 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
71export 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 */
78export 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 */
101export 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 */
110export 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. */
116export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number };
117
118export 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. */
123export 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 */
136export 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. */
149export 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}