Skip to main content

Expression Reference

bursar uses a safe expression language for pricing formulas. Same syntax works identically in Python and JavaScript.

All arithmetic is performed in exact decimal (Python decimal.Decimal, JavaScript decimal.js) — never binary floating point. So input_tokens * 0.1 + output_tokens * 0.2 with both equal to 1 evaluates to exactly 0.3, not 0.30000000000000004, and the result is byte-identical across both SDKs. The engine quantizes the final cost to 4 decimal places with ROUND_HALF_UP; it never truncates a sub-credit cost to zero.

Arithmetic

OperatorExampleDescription
+input_tokens * 0.01 + output_tokens * 0.03Addition
--cache_read_tokens * 0.001Subtraction / negation
*input_tokens * 0.01Multiplication
/output_tokens * (0.03 / 1000)Division
//input_tokens // 1000Floor division
%input_tokens % 1000Modulo
Exponentiation (**) is not allowed

The ** / exponentiation operator is rejected at config-load time in both SDKs (it raises ExpressionError). This is a deliberate DoS hardening: an unbounded exponent such as 9 ** 9 ** 9 could allocate gigabytes and hang the process. There is no constant-exponent carve-out — use repeated multiplication (x * x) if you need a power.

Division / modulo by zero raises

x / 0, x // 0 and x % 0 raise ExpressionError in both SDKs. They do not silently produce inf/NaN and never flow into a charge. Any expression that evaluates to a non-finite result (inf/NaN) is also rejected as an ExpressionError.

Comparisons

OperatorExample
==output_tokens == 0
!=output_tokens != 0
<output_tokens < 1000
<=output_tokens <= 1000
>output_tokens > 1000
>=output_tokens >= 1000
in"gpt-4" in model
not in"batch" not in job_type

Boolean

OperatorExample
andtool_calls > 0 and tool_calls <= 10
ortool_calls == 0 or cache_read_tokens > 0
not5 if not (tool_calls > 10) else 10

Ternary

Python-style conditional expression:

output_tokens * 0.5 if output_tokens > 1000 else output_tokens * 0.3

Functions

FunctionArityDescriptionExample
ceil(x)1Round upceil(input_tokens * 0.001)
floor(x)1Round downfloor(output_tokens / 1000)
round(x) / round(x, n)1–2Round half-up to nearest integer (or n decimals)round(input_tokens * 0.001)
min(a, b, ...)≥ 1Minimum of valuesmin(cost_a, cost_b)
max(a, b, ...)≥ 1Maximum of valuesmax(0, model_cost - allowance)
if(cond, then, else)exactly 3Conditionalif(input_tokens > 1000, cost_a, cost_b)
tier(val, t1, r1, [t2, r2, ...], default)even, ≥ 4Tiered pricingtier(input_tokens, 10000, 5, 100000, 10, 20)
clamp(x, lo, hi)exactly 3Range clampclamp(tool_calls, 0, 100)
percentile(p, v1, v2, ...)≥ 2, 0 ≤ p ≤ 100Percentile of valuespercentile(95, model_cost_1, model_cost_2)

Every function validates its argument count (and percentile's range) at config-load time. A wrong arity raises ExpressionError so a typo'd config fails fast rather than silently mispricing.

round() uses ROUND_HALF_UP in both SDKs (it deliberately diverges from Python's built-in banker's rounding) so the two engines agree to the last digit. Note that these are expression-level helpers a config author can call — the engine itself never implicitly truncates or rounds the total beyond the final 4dp quantization.

tier() details

Form: tier(value, t1, r1, [t2, r2, ...], default) — a value, one or more (threshold, rate) pairs, and a trailing default. The argument count must therefore be even and at least 4 (value + N≥1 pairs + default). Odd counts (3, 5, 7, …) and fewer than 4 arguments raise ExpressionError.

Returns r_i for the first threshold where value < t_i, else default:

tier(input_tokens, 10000, 5, 100000, 10, 20)
# value < 10000 → 5
# value < 100000 → 10
# otherwise → 20 (default)

percentile() details

Sorts values, computes p-th percentile (0 ≤ p ≤ 100) via linear interpolation. Requires at least 2 arguments; a p outside 0..100 raises ExpressionError.

percentile(50, input_tokens, output_tokens, tool_calls) # median of 3 values
percentile(0, a, b, c) # min
percentile(100, a, b, c) # max

Available Variables

VariableSource (Python)Source (JS)
input_tokensUsageMetrics.input_tokensinputTokens
output_tokensUsageMetrics.output_tokensoutputTokens
cache_read_tokensUsageMetrics.cache_read_tokenscacheReadTokens
cache_write_tokensUsageMetrics.cache_write_tokenscacheWriteTokens
tool_callslen(UsageMetrics.tool_calls)toolCalls.length
this_tool_callscount of calls matching the current tool keycount of calls matching the current tool key
search_queriesUsageMetrics.search_queriessearchQueries
search_resultsUsageMetrics.search_resultssearchResults
web_search_callsUsageMetrics.web_search_callswebSearchCalls
code_exec_callsUsageMetrics.code_exec_callscodeExecCalls

tool_calls is always the global total — the count of every tool call across all tools, everywhere it appears (models, search, cache, and every tools.* expression, including tools._default).

this_tool_calls is scoped only to tools.* expressions and is bound to the count of calls matching that specific tool. Referencing this_tool_calls in models, search, or cache is rejected with ExpressionError at config-load time — it is not a general-purpose variable.

Inside tools._default, this_tool_calls is bound to the count of calls that did not match any other, more specific tools.* key (the "everything else" bucket). This replaces the old behavior where _default (and every other tools.* key) received tool_calls rebound to a per-key count — that override is gone; tool_calls inside tools.* now behaves exactly like everywhere else (the global total), and this_tool_calls is the new, unambiguous way to get the per-key count.

tools:
code_exec: "this_tool_calls * 10 / 1000" # priced per call to code_exec specifically
_default: "this_tool_calls * 5 / 1000" # priced per call to any OTHER tool

With 2 code_exec calls and 3 calls to other tools: code_exec costs 2 * 10 / 1000, and _default costs 3 * 5 / 1000. (In the old, buggy behavior, code_exec's expression would have seen tool_calls == 5, the total across all tools, and mispriced accordingly.)

Safety

  • Python: AST-based validator with a strict node allowlist. No eval()/exec(), no attribute access (x.__class__), no subscripts (x[0]), no lambdas, comprehensions, f-strings or imports. The ** (ast.Pow) node is intentionally excluded from the allowlist.
  • JavaScript: Recursive-descent parser with a strict allowlist. No eval(), no Function() constructor. Variable lookup uses own-property checks (not the in operator), so prototype-chain identifiers (__proto__, constructor, prototype, toString, hasOwnProperty) are rejected as undefined variables rather than resolving to inherited members.
  • Variable-name validation: at config-load time every identifier must be a known metric variable (see the table above) or an allowed function; an unknown name (e.g. a typo like inputtokens) raises ExpressionError then, not at first runtime use.
  • Exact decimal, finite results: all math runs in Decimal; ** is rejected; division/modulo by zero raises; the final result is asserted finite. Non-finite results raise ExpressionError and never reach a charge.
  • All expressions are validated at config-load time. Invalid configs never reach the engine.