Expression reference
Bursar uses a safe expression language for pricing formulas. The same syntax works identically in Python and TypeScript.
All arithmetic is exact decimal (Python decimal.Decimal, TypeScript
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 6 decimal places with
ROUND_HALF_UP; it never truncates a sub-credit cost to zero.
When to use expressions
Rate cards can express most pricing with per_unit and sum charges — the
canonical standard card prices gpt-4o and gpt-4o-mini entirely that way.
Expressions handle the non-linear cases: formulas combining several
measures, or prices that need tier(), clamp(), or percentile(). Every
expression is validated at config load, so an invalid formula fails validation
instead of reaching a charge; a wrong function argument count surfaces as an
ExpressionError when the formula is first evaluated.
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 |
:::warning Exponentiation (**) is not allowed
The ** / exponentiation operator is rejected at config-load time in both
SDKs (it raises ExpressionError). This is 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.
:::
:::warning 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
| 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 |
in / not in are substring containment checks (both sides are coerced to
strings) so the two engines agree byte-for-byte.
:::warning Chained comparisons (a < b < c) are not supported
Both SDKs reject chained comparisons at parse time with an ExpressionError.
Python's chaining semantics (a < b and b < c) cannot be reproduced by the
TypeScript left-associative parser, so neither engine allows them — this keeps
the two engines byte-identical. Write the explicit and form instead:
tool_calls > 0 and tool_calls <= 10.
:::
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) |
The function set is intentionally small: no sum, abs, or arbitrary
math-library access. A wrong arity (and percentile's p outside 0..100)
raises ExpressionError when the formula is evaluated, so a typo fails
loudly instead of 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. These are expression-level helpers a config author can call — the
engine never implicitly rounds the total beyond the final 6dp 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
An expression charge may reference the measures declared by its operation, and
must reference at least one — a constant formula like 1 + 1 is rejected.
For example:
pricing:
operations:
completion:
measures:
input_tokens: { unit: token }
output_tokens: { unit: token }
rate_cards:
standard:
operations:
completion:
unmatched:
action: charge
charge:
type: expression
formula: input_tokens * 0.01 + output_tokens * 0.03
Dimensions such as model are selected through rate-card rules and are not
expression variables. An undeclared or misspelled measure name is rejected
when the configuration is loaded.
Safety
- Python: AST-based validator with a strict node allowlist. The
**(ast.Pow) node is intentionally excluded, as are attribute access, subscripts, lambdas, comprehensions, f-strings, and imports. There is noexec(); the onlyevalruns a pre-validated AST in a namespace with no builtins. - TypeScript: 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 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.
Related
- Pricing —
expressioncharges and how the engine evaluates them - Configuration and catalog revisions: formulas validated at configuration load
- Write safe pricing expressions — a hands-on walkthrough