SDK
Wallets and usage
Two books. Your end users' wallets hold money you credited them, which their runs spend. Your own balance is what the platform charges you. These namespaces wrap the wallets and usage APIs; Billing and the ledger explains how a run is reserved and settled.
Two units, two names
A wallet is in micro-USD — $5.00 is 5_000_000 — and its fields end in _micros. Your own balance is in tokens, and its fields end in _units. The names differ on purpose, so a figure cannot be printed with the wrong unit by accident.
micros("5.00")converts dollars to micro-USD from a string, an integer or (in Python) aDecimal, rounding a sub-micro remainder half up. It refuses a float, because0.1 + 0.2is not0.3in binary and a balance a thousandth of a cent out cannot be reconciled.dollars(amount)goes back exactly: aDecimalin Python (Decimal('5')), a string with six decimal places in TypeScript ("5.000000"). TypeScript also hasformatUsd(amount), which renders"$5.00".
Your users' wallets
A wallet belongs to one of your end users in one project, keyed by your own id for them. A run you start with end_user_id spends from it. Charging your users walks through pricing an agent and running on their behalf end to end.
from elan_sdk import Elan, dollars, micros
elan = Elan.from_env()
# Safe on every sign-in: it opens a wallet or returns the one that exists.
wallet, created = elan.credit.open("ada", display_label="Ada Lovelace")
# NOT idempotent: sending this twice credits twice. ref says why, afterwards.
elan.credit.add("ada", micros=micros("5.00"), ref="welcome-credit")
wallet = elan.wallets.get("ada")
print(dollars(wallet.available_micros)) # Decimal('5')
page = elan.wallets.statement("ada", limit=25)
for entry in page.entries: # newest first
print(entry.kind, entry.amount_micros, entry.ref)Reading and moving money are two objects, so the read path cannot move money by construction: wallets issues nothing but GETs, and credit holds every write.
| Method | What it does |
|---|---|
| wallets.list(*, limit=25, offset=0) | A WalletPage: wallets and total. |
| wallets.get(user_id) | One Wallet: status, balance_micros, held_micros, available_micros, currency, display_label and its dates. |
| wallets.statement(user_id, *, limit=25, offset=0) | Its movements, newest first: kind, amount_micros, ref (which may be null) and created_at. |
| credit.open(user_id, *, display_label, currency="USD") | Opens one, or returns the one that exists, with whether it was new: a tuple in Python, { wallet, created } in TypeScript. |
| credit.add(user_id, *, micros, ref) | Adds credit and returns the Transaction. Python refuses an amount that is not positive before sending. |
| credit.adjust(user_id, *, micros, ref) | A signed correction: negative claws back, positive refunds. Zero is refused. |
| credit.freeze / unfreeze / close(user_id) | Stops spending without losing the balance, starts it again, or closes the wallet for good — which needs a zero balance. All three go through set_status (setStatus). |
- Reading needs any credential for the project; every
creditmethod needs a tenant admin. Every method takesprojectand defaults to the client's; with neither, Python refuses locally. - An end user id may not be empty or contain
*,:or/, and both clients refuse one before sending.*means the tenant scope, and the other two would break the account id the wallet is keyed on. - A credit is a transfer. Neither the service nor the SDK deduplicates one, and the SDK never retries — a timeout does not say whether the request arrived. Keep your own record of what you have credited, and use
refso a double credit is at least visible afterwards. - What is left to spend is derived: balance less what is held for runs in flight, which is why a balance looks lower than expected mid-run. A wallet carries
available_micros; TypeScript'savailableMicros(wallet)computes the same subtraction.
Your own balance and usage
usage is what has been spent, as balances and as rows a person scrolls.
balance = elan.usage.balance()
print(balance.balance_units, balance.held_units, balance.available_units) # tokens
for row in elan.usage.agents(project="salmon"): # one row per agent version
print(row.target_slug, row.target_version, row.holds, row.total_tokens)
charge = elan.usage.invocation(invocation_id) # one run's cost, by model
for model in charge.usage:
print(model.model, model.prompt_tokens, model.completion_tokens)| Method | What it returns |
|---|---|
| usage.balance() | The tenant's own balance, in tokens: balance_units and held_units. Available is the difference — available_units in Python, availableUnits(balance) in TypeScript. The figure the service sends is kept beside it as reported_available_units (Python) and available_units (TypeScript). |
| usage.agents(*, project) | One row per agent and version — a version bump changes what an agent costs — with holds, reserved_units, settled_units and total_tokens. |
| usage.holds(*, limit, offset, project, agent, minutes) | The individual charges behind those rows. minutes is a window, and zero means all of it. |
| usage.invocation(invocation_id) | What one run cost and which models spent it, plus how the run ended when that was recorded. A run with no charge — an agent that declared no prices — is a NotFound. |
| usage.cost(*, project, since, until, limit, offset) | The platform's compute accruals behind the cost series. Python defaults to the last 30 days; TypeScript sends no window unless you give one. |
A hold's units are not a token count
reserved_units and settled_units are in the unit of the account that paid: tokens for a run on your own balance, micro-USD for one a wallet funded. Charting them as tokens draws fractions of a cent for every wallet run. Token counts are total_tokens and the token series below.
Series a dashboard draws
Six calls, each returning its own shape, so a measure a series does not have is absent rather than null.
| Call | Returns | Each bucket carries |
|---|---|---|
| analytics.revenue() | MoneySeries | What your end users paid you: micros per agent and SKU. |
| analytics.cost() | MoneySeries | What the platform charged you for pods — CPU, memory, disk — per SKU. Not model tokens, which your provider bills you for. |
| analytics.tokens() | TokenSeries | input_tokens, cached_tokens, output_tokens, total_tokens, runs. |
| analytics.invocations() | RunSeries | runs, ok, failed, llm_calls, tool_calls, events, tokens. |
| analytics.latency() | LatencySeries | p50, p95, p99 in milliseconds, and runs. |
| analytics.tools() | ToolSeries | Per tool: calls, runs, timed and that tool's own percentiles. |
from datetime import UTC, datetime, timedelta
since = datetime.now(UTC) - timedelta(days=7)
revenue = elan.analytics.revenue(grain="day", since=since, project="salmon")
print(revenue.total) # Decimal dollars, the whole window
for bucket in revenue: # a series iterates its buckets
print(bucket.bucket, bucket.target_slug, bucket.sku, bucket.amount)
latency = elan.analytics.latency(grain="hour", since=since, kind="agent")
for bucket in latency:
print(bucket.bucket, bucket.p50, bucket.p95, bucket.p99)- Every call takes the same window:
grain(hour,day,weekormonth; day by default),sinceanduntil(the last 30 days by default, half-open so adjacent windows never count a bucket twice), andproject. Python stamps a naivedatetimeas UTC. All but revenue and cost also filter bytarget, and invocations, latency and tools bykind(agentorworkflow). - The three token bands stack to the total.
input_tokensis already the prompt less its cached part, so input, cached and output add up tototal_tokens. They come from settled charges, so a run still in flight is not in them yet. - Latency excludes failed runs unless you ask, because a failure's duration is time to failure, a different measurement. Python takes
include_failures=True; the TypeScriptincludeFailuresoption is currently sent under a name the route does not read, so it has no effect.failures_includedon the series says which you got. - A money series totals itself —
totalandtotal_microsin Python,totalMicros(series)in TypeScript — and each Python bucket has anamountin dollars. A deployment with no ledger or no analytics warehouse answersNotWired, which is a reason to show an empty chart, not an error.