A cost class says who pays for a run. Every runtime declares one, because "free" has more than one reason and only some of them are free to the user.
TypeScript
type CostClass =
| "user-hardware"
| "user-subscription"
| "user-api-key"
| "open-weights"
| "frontier";
| Class |
Whose wallet |
Metered by Soba |
user-hardware |
Nobody's, at the moment of the run. Their own silicon |
no |
user-subscription |
Theirs, already spent. A plan they pay for anyway (Claude Pro/Max, ChatGPT Plus/Pro) |
no |
user-api-key |
Theirs, per token. Their own provider key, on their own machine |
no |
open-weights |
Yours. An open model on GPU, bought wholesale |
yes |
frontier |
Yours. A frontier provider API |
yes |
A single "is this free" flag would not do, because three of those are free to you and
only two are free to the user. Routing has to tell them apart, so the class travels
with the run and ends up on the usage record.
user-api-key is the one that is free to us and not free to them: Anthropic or
OpenAI invoices the person whose key it is. It used to be reported as frontier, which
put their spend in the same column as ours, and made the two impossible to rank against
each other in a route. They are different wallets, so they are different classes.
TypeScript
METERED_CLASSES = ["user-key", "app"]
Soba sells no inference and holds no provider key, so none of these is billed by Soba.
What Soba meters is cost, stamped on every run. What it bills is your plan, at your
price, through your own Stripe account.
Runs your app serves#
app is the one class Soba never runs. When routing lands on it, Soba counts the run
against the user's plan, applies any 402, and hands the run back to your server, which
serves it with the client and the key it already uses. Your key never reaches Soba.
It exists so that a user with nothing connected, or with a laptop that is asleep, still
gets an answer, and it is the only class that spends your money. The SDK's
fallback serves and reports these runs for you;
over the endpoint, the handback is a 409 serve_in_app. The usage you report is what
keeps allowances, conversion data and the dashboard complete, and
what makes these runs checkable against your provider bill.
app cannot be declared on a user's machine. Nothing a machine's owner writes can spend
your money.
The machine asks its class rather than assuming it#
Before advertising a runtime, the worker checks locally whether the CLI is signed in
and whether it is signed in against a subscription or against an API key — including
whether a metered key is sitting in the machine's environment, which is what the CLI
would reach for first. It costs no tokens and contacts nothing. A machine that would
spend a metered key reports frontier, so the meter and the wallet agree.
Guessing here is the failure the field exists to prevent.
The environment itself is never edited. An earlier version of this deleted the key
before spawning, which is a tidier-looking fix and the wrong one: a CLI's own
authentication methods are not ours to remove, and Anthropic's terms for running Claude
Code inside another product say so explicitly. So the check changes what the machine
claims, not what its owner's tools are allowed to read — and a machine that would have
to spend a key its owner has not allowed is withheld rather than quietly rewritten.
Two consequences, both deliberate#
A signed-out runtime is withheld#
Finding the binary is not the same as being able to run it. A signed-out CLI is kept
out of what the machine advertises, and reported to its owner with the fix, rather than
being routed to and failing at spawn on every run.
Only a positive finding withholds. A check that cannot tell cheaply advertises the
runtime anyway, because a probe that fails must not hide a runtime that works.
A metered key in the environment changes what is advertised#
The worker inherits the owner's shell, and that shell may hold ANTHROPIC_API_KEY.
The CLI reaches for it ahead of the login, so a run advertised as user-subscription
would quietly bill their API account while Soba, reading a free cost class, charges
nothing. Both sides lose and neither is told.
So the key is read at detection and answered there:
allowMeteredKeys |
What the machine does |
true |
Advertises those runtimes as user-api-key — real money, and the owner's |
false (default) |
Withholds them, and tells the owner which variable and both ways out |
The environment is handed to the CLI exactly as the owner left it either way. That
flag is grant-only: there is no way for an app to ask for it, because an app must
not be able to spend someone else's money.
The tier is announced before the output#
The class that won is on the opening status event, before any text, and is stamped
onto the run's usage at the end.
JSON
{"type":"status","message":"Starting","tier":"user-subscription"}
A cheaper tier covering for a sleeping laptop will visibly underperform the one the
user expected. Silent degradation is worse than a visible downgrade, so the app is
told which tier it got before the answer arrives, not after.
Self-describing usage#
TypeScript
interface RunUsage {
costClass?: CostClass; // who pays for this run
costMicros?: number; // absent when nothing was spent
model: string;
provider?: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
webSearches: number;
}
costClass is on the usage record so a meter never has to remember what it routed to
in order to know whose money was spent.
It is also why user-key and app are separate classes. Both spend real money by the
token; the class says whose. A run on the machine owner's own provider key is honestly
user-key: real money is being spent, and
allowMeteredKeys is how they consented to
spend it. It still costs you nothing, because the key was never yours. costMicros says
how much was spent, whoever spent it, and is absent when nothing was.
Usage is absent altogether when the runtime cannot report it. A subscription-backed CLI
often can't, which is exactly why those runs are free.
Costs are integer micros#
Throughout. No floats, no currency strings. route.maxCostMicros stops a run that
reaches its ceiling, and that check happens between turns, because nobody knows a
turn's cost before it happens.
The guarantee is "stops as soon as it knows", not "never exceeds".