Repository navigation
FinOps
-
Every model call is accounted: a
UsageRecord(caller, model, input/output/cache tokens, USD,costSource) per call, accumulated in graph state. -
Cost source — the call's model provider: a cost the provider reports
in its answer (Jev, OpenRouter's
usage.cost) is exact ("api") and always comes first; else the provider's price table (ModelCost.fromPrices) or the component's ownprice("price-table"): uncached input × input price + cache read/write × cache prices (fallback: input price) + output × output price. A call with neither fails — never silently free. -
Tool costs: what a tool spends on models through the framework is recorded as the tool's —
tool:<name>— in the same loop as the agent's model calls, so it counts in the budget check before the next model call, in the budgets' counters and in a failed agent's spend. A decision a tool's service makes throughROUTER_FACTORYduring the call (job-scout's fit judge) is accounted this way, with the decision's model and exact cost; a knowledge-base search reports its retrieval cost underrag:<name>(costSource: "tool"). -
Services that are not models (#280 S43): a
@Toolor an@ActiontakesSpendRecorderin itsdepsand records what one call of the service cost —this.spendRecorder.record({ service: "serp-api", cost: usd(0.002) }). The record (model: the service,costSource: "tool") joins the current tool call's spend (tool:<name>) or the step's (action:<name>, categorytools), so it counts in every budget of the execution — the next check (before the agent's next model call, or the next agent or router visit) stops it withBudgetExceededError— and in the run'sspend. Called outside a step or a tool call (a constructor, a timer after the call ended) it throwsSpendOutsideStepError([spend.outside-step]).ExecutionContextstays read-only: spend never goes through it.
import { SpendRecorder, Tool, type ToolHandler } from "agboss";
import { usd } from "agboss/units";
@Tool({
name: "web_search",
description: "Searches the web (a paid API)",
input: WebQueryDto,
output: WebResultsDto,
deps: [SpendRecorder],
})
export class WebSearchTool implements ToolHandler<WebQueryDto, WebResultsDto> {
constructor(private readonly spendRecorder: SpendRecorder) {}
run({ query }: WebQueryDto): Promise<WebResultsDto> {
this.spendRecorder.record({ service: "serp-api", cost: usd(0.002) }); // tool:web_search
return Promise.resolve({ text: `results for ${query}` });
}
}A decision made through ROUTER_FACTORY needs no recording — the framework accounts it:
import { ROUTER_FACTORY, Tool, type RouterEngine, type ToolHandler } from "agboss";
@Tool({
name: "exchange_rate",
description: "Today's rate, checked by a cheap decision model",
input: RateQuery,
output: Rate,
deps: [ROUTER_FACTORY],
})
export class ExchangeRate implements ToolHandler<RateQuery, Rate> {
private readonly checker: RouterEngine;
constructor(routers: (name: string) => RouterEngine) {
this.checker = routers("rate-check");
}
async run({ pair }: RateQuery): Promise<Rate> {
// the decision's spend is recorded as tool:exchange_rate
await this.checker.route({
input: `${pair} 3.71`,
options: [
{ name: "plausible", description: "the rate is within a day's range" },
{ name: "suspicious", description: "it is not" },
],
});
return { rate: `${pair} 3.71` };
}
}-
Report: every run returns
CostReport(total, calls, cache-read tokens, per caller) and what it was allowed to spend (the least its budgets had left at its start); the CLI prints it. Per-call traces: Tracing.
Money limits are @Budget components (#261): one template for every budget — the decorator says
what it limits, maxSpend() how much, its data through the constructor like any component's. Every
budget of the application is listed in @Application({ budgets }), written in
config/budget.config.ts; name ends with -budget, the class with Budget.
// config/budget.config.ts
import { Budget, BudgetPeriod, BudgetScope, type BudgetHandler } from "agboss";
import { usd, type Usd } from "agboss/units";
@Budget({
name: "application-daily-budget",
scope: BudgetScope.Application, // the whole application per period
period: BudgetPeriod.Day, // UTC: Day | Week | Month
})
export class ApplicationDailyBudget implements BudgetHandler {
maxSpend(): Usd {
return usd(20);
}
}
@Budget({ name: "tenant-daily-budget", scope: BudgetScope.Tenant, period: BudgetPeriod.Day })
export class TenantDailyBudget implements BudgetHandler {
maxSpend(): Usd {
return usd(5); // per organization; its own amount comes from tenant data (#261 PR 3)
}
}
@Budget({ name: "execution-budget", scope: BudgetScope.Execution }) // no workflow: the default
export class ExecutionBudget implements BudgetHandler {
maxSpend(): Usd {
return usd(0.1); // one full pass of app.execute, children included
}
}- The options are a union discriminated by
scope:periodis required forTenant/Applicationand does not compile onExecution;workflow: X(a workflow's own execution budget) only onExecution— without it, the default execution budget of every workflow without its own. - A budget is a component: injectable (
deps: [ApplicationDailyBudget]), created at start with the application, replaced in a test withmockOf(TenantDailyBudget).maxSpend.mockReturnValue(usd(0.01)). -
Who reads what: a budget's data come through its constructor — the injected
ExecutionContext(the execution, its step,executionContext.tenant), the project's tenant context (deps: [ShopTenantContext], its own amount from server-side tenant data) or a service such asBillingService— a trusted source, never the request body.
// config/budget.config.ts — the tenant's own daily amount, from its context (its fields: Context-And-Authorization)
@Budget({
name: "tenant-daily-budget",
scope: BudgetScope.Tenant,
period: BudgetPeriod.Day,
deps: [ShopTenantContext],
})
export class ShopTenantDailyBudget implements BudgetHandler {
constructor(private readonly shopTenantContext: ShopTenantContext) {}
maxSpend(): Usd {
return usd(this.shopTenantContext.dailyBudgetUsd);
}
}
@Budget({
name: "screening-execution-budget",
scope: BudgetScope.Execution,
workflow: ScreeningWorkflow, // its own: replaces the default for executions of this workflow
})
export class ScreeningExecutionBudget implements BudgetHandler {
maxSpend(): Usd {
return usd(0.5);
}
}Every workflow of the application is covered by its own execution budget or by the one default, and there is at least one tenant budget and one application budget — so no execution runs without a cap on its cost, its tenant's and the application's. All violations at once, before any model call:
| Code | When |
|---|---|
[budget.execution-budget-required] |
a workflow with no own execution budget and no default — screening-workflow has no execution budget and there is no default — add one with workflow: ScreeningWorkflow or one without workflow
|
[budget.duplicate-execution-budget] |
two own execution budgets for one workflow |
[budget.duplicate-default-execution-budget] |
two execution budgets without workflow
|
[budget.tenant-budget-required] |
no BudgetScope.Tenant budget (a single-tenant application has its one tenant, "default") |
[budget.application-budget-required] |
no BudgetScope.Application budget |
[budget.not-a-budget] |
a class in budgets without @Budget
|
-
maxSpend()once per execution, at its start — in the frame of its start step, so an injectedExecutionContextreads the execution (itstenant, its own fields,step.nodeIdis the start's) — and kept in the execution's state for the whole execution: a resume (also after a restart) and a recovery count against the same amounts; a plan change applies from the next execution. The framework never callsmaxSpend()outside an execution. -
Which budgets: the execution budget of the started workflow (its own, else the default),
every tenant budget (keyed by the execution's
tenantId) and every application budget (keyed by the application's name). Several budgets of one scope with different periods work at once (tenant daily + tenant monthly). -
Before the first step every budget is checked against its counter in the current period:
one exhausted rejects the execution —
BudgetExceededError, anonExecutionRejected(codelimit.budget) — and the refused request leaves no trace: no thread (a new thread is created only once the budgets pass;executionContext.threadIdread inmaxSpend()of an execution on a new thread throwsExecutionContextUnavailableError), no record, no checkpoint, no model call, and the amounts it read are not kept as the last known limit. -
During the execution every model call's (and paid service's) real cost is added to all of
its counters as the execution streams. Before every agent and router step the counters are read
again — the tenant's other executions count — and every budget is checked; before every model
call of an agent's loop too. The first exhausted stops the execution with
BudgetExceededError(into the step's.onError(BudgetExceededError, …)if it has one, else the execution fails; the spend so far is in the ledger and the failed record). - A parallel launch (#273): a branch is checked against the spend at the launch plus its siblings that already ended.
-
Nothing is reserved: a budget protects from draining money, it is not exact accounting —
parallel executions of one tenant may overrun it a little (each sees the others' calls once they
are recorded), and a budget may be exceeded by the cost of one call (its cost is known after it;
a call is not pre-checked by its
maxTokens;MODEL_MAXmakes one call bounded only by the model).
BudgetExceededError {
code: "limit.budget",
message: "tenant acme spent $40.05 today; tenant-daily-budget allows $40",
budget: { name: "tenant-daily-budget" }, // ComponentRecord: nameOf(TenantDailyBudget)
scope: BudgetScope.Tenant,
key: "acme", // the application's name, the tenantId, or the execution's id
maxSpend: 40,
spent: 40.05,
}
The application's CostLedgerRepository (#261 S28) keeps one counter per budget, scope key and
period — findSpentByCounter({ budget, scope, key, period }), saveAll(counters, records) (each
call's cost added to every counter, atomically per counter) — and the last maxSpend() read per
budget and key (findMaxSpendByAccount, saveMaxSpend: "the last known limit" for a report
outside an execution). A period is named by its first UTC day: 2026-10-18 for that day,
2026-10-12 for the week starting Monday the 12th, 2026-10-01 for the month; an execution budget's
counter has no period. Listed in @Application({ providers }) (a class that
extends CostLedgerRepository), else the built-in in-memory one — each instance counts on its own
and the counters reset on restart; SqliteCostLedgerRepository (#265) shares them between the
instances on one file and survives a restart (a file written before budgets has its old per-workflow
day counters dropped).
A budget is unit-tested without the container:
new ShopTenantDailyBudget(testExecutionContext({ tenantContext: ShopTenantContext, tenant: { tenantId: "acme", dailyBudgetUsd: 40 } }).tenant).maxSpend()
is usd(40). An application test reaches an exhausted budget the real way —
mockOf(TenantDailyBudget).maxSpend.mockReturnValue(usd(0.01)) plus a scripted cost
(replyWith(…, { cost: usd(0.02) })) — and checks its own reaction (the .onError path, the
rejection). There is no limits option in testWith.
Counts protect the graph, not money (Workflow):
| Limit | Error | Meaning |
|---|---|---|
@Workflow({ maxSteps }) |
StepsLimitExceededError (workflow, maxSteps, steps: support-workflow took 41 steps; its maxSteps is 40) |
agent and router visits per execution (default (agents + routers) × 3) |
maxVisits on @Router / @Agent
|
VisitsLimitExceededError (component, nodeId, maxVisits, visits) |
visits of one node per execution |
One class per limit, all subclasses of LimitExceededError (.onError(LimitExceededError, …) takes
any; codes limit.steps, limit.visits, limit.budget). maxSteps is checked at assembly:
[limits.max-steps-too-low] — no finish fits in a hand-set maxSteps, or the default cannot hold the
worst case of a per-item launch (maxItems runs: set @Workflow({ maxSteps }), the default never
grows silently) —, [limits.branch-exceeds-max-steps], [limits.max-steps-unused].
npm run studio runs the flow graph with its steps and visits limited; budgets are opened per
execution by the app, so Studio's own runs are not capped by them. Optional extra backstop outside
the app: a spending limit on the OpenRouter key itself.
Every ExecutionOutput carries spend (CostReport) — the core always reports it; each client
decides what to show:
| Field | Meaning |
|---|---|
totalUsd, calls, cacheReadTokens
|
the turn in total |
byCategory |
agents, routing, guards, review, tools, compaction, retrieval — every key present |
byModel, byCaller
|
the same money by model and by caller |
trace |
every call in order: caller, category, model, cost, how it was priced, tokens |
Categories come from the caller: tool:* → tools, compaction → compaction, rag:* → retrieval, router:guard:* → guards, router:quality:* →
review, other router:* → routing, an agent name → agents. A resumed (paused) run reports the whole
run.
The CLI and the chat print it every turn:
thread 6cc2173e-… · http://localhost:3000/project/…/sessions/6cc2173e-…
researcher · stop: jev confidence 0.96
agents $0.002332 · routing $0.000038 · guards $0.000033 · tools $0.001000
1. router:guard:prompt_injection typesafe/jev-1.13 $0.000014 api-priced
2. router:main typesafe/jev-1.13 $0.000016 api-priced
3. researcher kimi-k2.6 $0.000426 306 in / 116 out
5. tool:exchange_rate exchange_rate $0.001000 tool-reported
…
total $0.003403 (8 calls)
api-priced = exact price from the provider (Jev bills per decision); N in / M out = tokens ×
price table; tool-reported = the tool's own cost.
npm run eval / npm run replay / compare run within the application's budgets: a replayed or
compared execution is an execution like any (its tenant "default"), and stops the evaluation once a
tenant or application budget is exhausted; the judge's own calls are outside any execution — they are
counted in the application budgets and stop scoring once one is exhausted by its last known limit
(the maxSpend() the last execution read). See Terns and evaluation.
Unit tests use fakes and cost $0; real calls only in make smoke.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback — declared compensation
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Naming
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)