Skip to content
109 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/WORKSPACE.md, "Budgets"). The
6 * workspace's own limit and AI credit are billing's, checked by the
7 * compute gate. The agent's monthly and daily caps are checked here, from
8 * the spend this service rolls up per agent, 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
71/** The tokens one answer used, by kind. */
72export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number };
73
74export function totalTokens(tokens: Tokens): number {
75 return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite;
76}
77
78/** What `tokens` cost at `price` (dollars per million tokens), in millionths of a dollar. */
79export function costMicros(tokens: Tokens, price: TokenPrice | null): number {
80 if (!price) return 0;
81 const micros = tokens.input * price.input + tokens.output * price.output + tokens.cacheRead * price.cacheRead + tokens.cacheWrite * price.cacheWrite;
82 return Math.ceil(Math.max(0, micros));
83}
84
85/**
86 * What a reply counts against the agent's budget: on g1t's models, the
87 * model's cost with the model margin; on the workspace's own provider,
88 * nothing for the model (the provider bills the workspace). On both, the
89 * agent rate (`agent_tokens` or `agent_tokens_own` in the price book, per
90 * million tokens) on every token.
91 */
92export function chargedMicros(input: {
93 costMicros: number;
94 hosted: boolean;
95 marginPercent: number;
96 ratePerMillionMicros: number;
97 tokens: number;
98}): number {
99 const model = input.hosted ? Math.ceil((input.costMicros * (100 + Math.max(0, input.marginPercent))) / 100) : 0;
100 const rate = Math.ceil((Math.max(0, input.tokens) * Math.max(0, input.ratePerMillionMicros)) / 1_000_000);
101 return model + rate;
102}
103
104/** The agent's presence, as the Agents page shows it. */
105export function agentStatus(input: { busyUntil: string | null; now: Date; blocked: boolean }): AgentStatus {
106 if (input.busyUntil && Date.parse(input.busyUntil) > input.now.getTime()) return "working";
107 if (input.blocked) return "out_of_budget";
108 return "idle";
109}