Skip to content
Claude edited this page Oct 18, 2026 · 34 revisions

FinOps

Measure

  • 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 own price ("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 through ROUTER_FACTORY during 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 under rag:<name> (costSource: "tool").
  • Services that are not models (#280 S43): a @Tool or an @Action takes SpendRecorder in its deps and 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>, category tools), 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 with BudgetExceededError — and in the run's spend. Called outside a step or a tool call (a constructor, a timer after the call ended) it throws SpendOutsideStepError ([spend.outside-step]). ExecutionContext stays 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.

Budgets

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: period is required for Tenant / Application and does not compile on Execution; workflow: X (a workflow's own execution budget) only on Execution — 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 with mockOf(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 as BillingService — 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);
  }
}

Assembly

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

At run time

  • maxSpend() once per execution, at its start — in the frame of its start step, so an injected ExecutionContext reads the execution (its tenant, its own fields, step.nodeId is 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 calls maxSpend() 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, an onExecutionRejected (code limit.budget) — and the refused request leaves no trace: no thread (a new thread is created only once the budgets pass; executionContext.threadId read in maxSpend() of an execution on a new thread throws ExecutionContextUnavailableError), 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_MAX makes 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 ledger

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).

Tests

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.

Steps and visits

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.

Turn financials — part of every result

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.

Eval spend

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.

Tests

Unit tests use fakes and cost $0; real calls only in make smoke.

Clone this wiki locally