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
| Operator | Example | Description |
|---|---|---|
+ | input_tokens * 0.01 + output_tokens * 0.03 | Addition |
- | -cache_read_tokens * 0.001 | Subtraction / negation |
* | input_tokens * 0.01 | Multiplication |
/ | output_tokens * (0.03 / 1000) | Division |
// | input_tokens // 1000 | Floor division |
% | input_tokens % 1000 | Modulo |
**) is not allowedThe ** / 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.
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
| Operator | Example |
|---|---|
== | 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
| Operator | Example |
|---|---|
and | tool_calls > 0 and tool_calls <= 10 |
or | tool_calls == 0 or cache_read_tokens > 0 |
not | 5 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
| Function | Arity | Description | Example |
|---|---|---|---|
ceil(x) | 1 | Round up | ceil(input_tokens * 0.001) |
floor(x) | 1 | Round down | floor(output_tokens / 1000) |
round(x) / round(x, n) | 1–2 | Round half-up to nearest integer (or n decimals) | round(input_tokens * 0.001) |
min(a, b, ...) | ≥ 1 | Minimum of values | min(cost_a, cost_b) |
max(a, b, ...) | ≥ 1 | Maximum of values | max(0, model_cost - allowance) |
if(cond, then, else) | exactly 3 | Conditional | if(input_tokens > 1000, cost_a, cost_b) |
tier(val, t1, r1, [t2, r2, ...], default) | even, ≥ 4 | Tiered pricing | tier(input_tokens, 10000, 5, 100000, 10, 20) |
clamp(x, lo, hi) | exactly 3 | Range clamp | clamp(tool_calls, 0, 100) |
percentile(p, v1, v2, ...) | ≥ 2, 0 ≤ p ≤ 100 | Percentile of values | percentile(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
| Variable | Source (Python) | Source (JS) |
|---|---|---|
input_tokens | UsageMetrics.input_tokens | inputTokens |
output_tokens | UsageMetrics.output_tokens | outputTokens |
cache_read_tokens | UsageMetrics.cache_read_tokens | cacheReadTokens |
cache_write_tokens | UsageMetrics.cache_write_tokens | cacheWriteTokens |
tool_calls | len(UsageMetrics.tool_calls) | toolCalls.length |
this_tool_calls | count of calls matching the current tool key | count of calls matching the current tool key |
search_queries | UsageMetrics.search_queries | searchQueries |
search_results | UsageMetrics.search_results | searchResults |
web_search_calls | UsageMetrics.web_search_calls | webSearchCalls |
code_exec_calls | UsageMetrics.code_exec_calls | codeExecCalls |
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(), noFunction()constructor. Variable lookup uses own-property checks (not theinoperator), 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) raisesExpressionErrorthen, 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 raiseExpressionErrorand never reach a charge. - All expressions are validated at config-load time. Invalid configs never reach the engine.