Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Chat and workspace agents: channels, DMs and named agents you talk to | 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 | */ | |
| 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 | /** The tokens one answer used, by kind. */ | |
| 72 | export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }; | |
| 73 | ||
| 74 | export 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. */ | |
| 79 | export 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 | */ | |
| 92 | export 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. */ | |
| 105 | export 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 | } |