UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

140 lines • 7.38 kB
/** * cost — shared cost-accounting helper emitted by LLMCall + Agent. * * Pattern: Strategy (PricingTable port) + Event emission (typedEmit). * Role: core/ layer. When a runner is configured with a PricingTable, * every LLM response drives a `cost.tick` event carrying per-call * tokens/USD plus cumulative run totals. When a `costBudget` is * also set, the first crossing emits `cost.limit_hit` with * `action: 'warn'` (library never auto-aborts; consumers decide). * Emits: agentfootprint.cost.tick * agentfootprint.cost.limit_hit */ import { typedEmit } from '../recorders/core/typedEmit.js'; /** * Normalize a `costBudget`, refusing a shape that cannot mean anything. * * `'halt'` is refused for `LLMCall`: halting means "stop at the next iteration * boundary", and one call has no next boundary. Accepting it there would give * a consumer a stop button wired to nothing — the same silent no-op * {@link assertCostBudgetHasPricing} exists to remove. * * @param runner the class name for the message — `'Agent'` or `'LLMCall'`. */ export function resolveCostBudget(runner, costBudget) { if (costBudget === undefined) return undefined; if (typeof costBudget === 'number') { if (!Number.isFinite(costBudget) || costBudget <= 0) { throw new Error(`${runner}: costBudget must be a positive number of USD, got ${String(costBudget)}.`); } return { usd: costBudget, onExceed: 'warn' }; } if (costBudget === null || typeof costBudget !== 'object') { throw new Error(`${runner}: costBudget must be a number of USD or { usd, onExceed: 'warn' | 'halt' }, got ` + `${typeof costBudget}.`); } const { usd, onExceed } = costBudget; if (typeof usd !== 'number' || !Number.isFinite(usd) || usd <= 0) { throw new Error(`${runner}: costBudget.usd must be a positive number of USD, got ${String(usd)}.`); } if (onExceed !== 'warn' && onExceed !== 'halt') { throw new Error(`${runner}: costBudget.onExceed must be 'warn' or 'halt', got ${String(onExceed)}. ` + `'warn' emits cost.limit_hit once and keeps going (what a bare number does); 'halt' ` + `stops the loop at the next iteration boundary. A value this library does not ` + `recognise is refused rather than treated as one of them — guessing here would mean ` + `guessing whether to spend your money.`); } if (runner === 'LLMCall' && onExceed === 'halt') { throw new Error(`LLMCall: costBudget.onExceed 'halt' has nothing to halt. Halting stops the loop at the ` + `next iteration boundary, and an LLMCall makes ONE call — there is no next boundary, so ` + `this would read as enforcement and behave as a no-op. Use 'warn' here (or a bare ` + `number, which means the same), and subscribe to agentfootprint.cost.limit_hit. ` + `'halt' belongs on an Agent, which has a loop to stop.`); } return { usd, onExceed }; } /** * Refuse a `costBudget` with no `pricingTable` to measure it against (8.13.0). * * The budget is denominated in USD and {@link emitCostTick} returns on its first * line when there is no pricing table, so before this refusal the pair emitted * NOTHING — no `cost.tick`, and no `cost.limit_hit` however much a run spent. A * budget that cannot be crossed is not a lenient budget; it is a guard rail that * was never installed, and it looks identical from the outside to one that * simply has not been hit yet. * * Shared by BOTH runners that take the pair. Leaving the sibling class with the * same silent no-op would be a governance fix that itself drops half the cases. * * @param runner the class name for the message — `'Agent'` or `'LLMCall'`. */ export function assertCostBudgetHasPricing(runner, pricingTable, costBudget) { if (costBudget === undefined || pricingTable !== undefined) return; throw new Error(`${runner}: costBudget was set without a pricingTable, so it can never be reached — the ` + `budget is USD, and only a pricingTable turns tokens into USD. Nothing is emitted: no ` + `cost.tick, and no cost.limit_hit however much the run spends. Pass \`pricingTable\` too ` + `— any object with { name, pricePerToken(model, kind) } where kind is 'input' | 'output' ` + `| 'cacheRead' | 'cacheWrite' and the number is USD for ONE token. The library ships no ` + `table because prices are yours to keep current. Or drop \`costBudget\`.`); } /** * Emit `cost.tick` for the just-completed LLM response and, if the * consumer set a `costBudget`, emit a one-shot `cost.limit_hit` the first * time cumulative USD crosses the budget. Does nothing when no * `pricingTable` is configured — zero overhead on runs without costing. * * Scope must carry the running cumulative counters; callers seed them * in their Seed stage. */ export function emitCostTick(scope, pricingTable, costBudget, billedBy, usage) { if (!pricingTable) return; const budget = costBudget; const { model } = billedBy; const usdThisCall = pricingTable.pricePerToken(model, 'input') * usage.input + pricingTable.pricePerToken(model, 'output') * usage.output + (usage.cacheRead !== undefined ? pricingTable.pricePerToken(model, 'cacheRead') * usage.cacheRead : 0) + (usage.cacheWrite !== undefined ? pricingTable.pricePerToken(model, 'cacheWrite') * usage.cacheWrite : 0); scope.cumTokensInput = (scope.cumTokensInput ?? 0) + usage.input; scope.cumTokensOutput = (scope.cumTokensOutput ?? 0) + usage.output; scope.cumEstimatedUsd = (scope.cumEstimatedUsd ?? 0) + usdThisCall; typedEmit(scope, 'agentfootprint.cost.tick', { scope: 'iteration', // Attribution rides the tick itself. A consumer summing `estimatedUsd` // per model or per vendor should not have to join this event against a // `stream.llm_start` by runtimeStageId to find out what it paid for. model, ...(billedBy.provider !== undefined && { provider: billedBy.provider }), tokensInput: usage.input, tokensOutput: usage.output, estimatedUsd: usdThisCall, cumulative: { tokensInput: scope.cumTokensInput, tokensOutput: scope.cumTokensOutput, estimatedUsd: scope.cumEstimatedUsd, }, }); // First-time crossing of costBudget — emit limit_hit once per run. // // `action` now tells the truth about what happens next: under `'halt'` the // Route decider ends the loop at the next boundary, so the honest word is // 'abort'; under `'warn'` the run carries on and always did. Before 8.14.0 // this always said 'warn' — correct — while the commentary template said the // run "stopped", which was not. if (budget !== undefined && !scope.costBudgetHit && scope.cumEstimatedUsd > budget.usd) { scope.costBudgetHit = true; typedEmit(scope, 'agentfootprint.cost.limit_hit', { kind: 'max_cost', limit: budget.usd, actual: scope.cumEstimatedUsd, action: budget.onExceed === 'halt' ? 'abort' : 'warn', }); } } //# sourceMappingURL=cost.js.map