JuliusBrussee/caveman · error · Error
cave_budget_denomination_ambiguous
Error message
cave_budget_denomination_ambiguous
What it means
Thrown by normalizeRunBudget when the budget's denomination is ambiguous: exactly one of maxUsd and maxTokens must be set, and this error fires when both are set or neither is. The library fails closed at validation time — before any provider call — rather than silently picking a cap or running unbounded.
Source
Thrown at packages/agent/src/budget.ts:93
export interface NormalizedBudget {
readonly denomination: BudgetDenomination;
readonly max: number;
readonly initial: number;
readonly outputFloorTokens: number;
readonly onExhausted: "compact" | "stop";
readonly compaction: NormalizedCompaction;
}
/**
* Validate a caller-supplied budget. Fails closed: an ambiguous, unbounded, or
* self-contradicting budget is rejected before the first provider call rather
* than silently degrading into no cap at all.
*/
export function normalizeRunBudget(budget: RunBudget): NormalizedBudget {
const usd = budget.maxUsd !== undefined;
const tokens = budget.maxTokens !== undefined;
if (usd === tokens) throw new Error("cave_budget_denomination_ambiguous");
const denomination: BudgetDenomination = usd ? "usd" : "tokens";
const max = usd ? budget.maxUsd! : budget.maxTokens!;
if (!Number.isFinite(max) || max <= 0) throw new Error("cave_budget_max_invalid");
if (denomination === "tokens" && !Number.isSafeInteger(max)) {
throw new Error("cave_budget_max_invalid");
}
const wrongInitial = denomination === "usd" ? budget.initialTokens : budget.initialUsd;
if (wrongInitial !== undefined) throw new Error("cave_budget_denomination_ambiguous");
const declaredInitial = denomination === "usd" ? budget.initialUsd : budget.initialTokens;
const initial = declaredInitial ?? max;
if (!Number.isFinite(initial) || initial <= 0 || initial > max) {
throw new Error("cave_budget_initial_invalid");
}
if (denomination === "tokens" && !Number.isSafeInteger(initial)) {
throw new Error("cave_budget_initial_invalid");
}
const outputFloorTokens = budget.outputFloorTokens ?? OUTPUT_CLAMP_FLOOR_TOKENS;
if (!Number.isSafeInteger(outputFloorTokens) || outputFloorTokens <= 0) {View on GitHub (pinned to 27d5a3981a)
Solutions
- Set exactly one of maxUsd or maxTokens on the budget object and delete the other.
- If merging config layers, explicitly delete the unused denomination key after the merge instead of relying on overrides.
- Add a build-time or test-time assertion that calls normalizeRunBudget on the shipped config so ambiguity surfaces in CI, not in production.
Example fix
// before
const budget = { ...defaults, ...userOverrides }; // defaults has maxUsd, userOverrides has maxTokens -> both set
// after
const budget = { ...defaults, ...userOverrides };
if (budget.maxTokens !== undefined) delete budget.maxUsd;
else if (budget.maxUsd === undefined) throw new Error("budget must set maxUsd or maxTokens"); Defensive patterns
Strategy: validation
Validate before calling
function pickDenomination(b: RunBudget): { maxUsd: number } | { maxTokens: number } {
const usd = b.maxUsd !== undefined;
const tok = b.maxTokens !== undefined;
if (usd === tok) throw new Error("set exactly one of maxUsd or maxTokens");
return usd ? { maxUsd: b.maxUsd! } : { maxTokens: b.maxTokens! };
} Type guard
function isUnambiguousBudget(b: RunBudget): boolean {
return (b.maxUsd !== undefined) !== (b.maxTokens !== undefined);
} Try / catch
try {
normalizeRunBudget(budget);
} catch (e) {
if (e instanceof Error && e.message === "cave_budget_denomination_ambiguous") {
throw new Error(`Budget must set exactly one of maxUsd or maxTokens; got maxUsd=${budget.maxUsd} maxTokens=${budget.maxTokens}`);
}
throw e;
} Prevention
- After merging config layers, delete the unused denomination key explicitly.
- Model budgets in your code as a discriminated union ({kind:'usd',max:number} | {kind:'tokens',max:number}) and convert to the library's shape at the boundary.
- Run normalizeRunBudget in a config unit test so ambiguity fails in CI.
When it happens
Trigger: Passing a RunBudget with both maxUsd and maxTokens defined; passing a RunBudget with neither (an unbounded run); passing an object where one field is explicitly undefined and the other absent, both of which count as unset.
Common situations: Spreading two config objects together (a USD default plus a token override) so both fields end up defined; refactoring a caller from maxUsd to maxTokens and leaving the old field populated; a config file where the budget section is left empty, producing neither field.
Related errors
- cave_budget_max_invalid
- cave_budget_output_floor_invalid
- cave_budget_on_exhausted_invalid
- cave_budget_initial_invalid
- cave_budget_release_invalid
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/b15226b1c10db315.
Report an issue: GitHub.