Purchasing rules
If you send only model, the exchange buys the offer with the lowest cost for that model. The cost is the
estimated charge plus the value of the predicted wait. See Price and wait.
Add an exchange object to the body to change that. It sets which models compete, how the exchange
ranks them, and which limits an offer must meet. It is one optional root field, so an unchanged OpenAI
or Anthropic SDK can send it.
How the exchange buys
Providers post offers on the book. An offer is a standing price for one model, with the deadlines that the provider promises. For each request:
- The exchange drops each offer that fails a limit of your request.
- It ranks the rest by cost. A strategy can change the ranking.
- The best offer wins. Its price cap is the most that the job can cost: the worst case, where the answer reaches its output limit and no cache saving counts.
- The exchange holds the cap on your balance before the first token.
- You pay the charge: the tokens that the provider really used. The rest of the hold returns to your balance. The charge never exceeds the cap.
Each settled job has a receipt. See Receipts.
If the winner fails before it writes any output, the exchange can try the next offer. After the first output, it never switches provider.
The controls you use most
Each field is optional.
cap_usd: an offer with a higher price cap does not compete.limits.ttft_ms: an offer that promises a slower first token does not compete.candidates.providers.exclude: a listed provider does not compete.privacy.retention:nonelimits offers to providers that do not keep your prompt. See Data handling.rank.strategy, or a model suffix:price,fastest,smartestorauto. See Strategy.
response = client.chat.completions.create(
model="liquid.auto",
messages=[{"role": "user", "content": "Summarise this."}],
extra_body={
"exchange": {
"cap_usd": 0.05,
"limits": {"ttft_ms": 1500},
"candidates": {"providers": {"exclude": ["px_slow"]}},
}
},
)
If no offer meets the limits, the exchange returns an error and charges nothing. See Refusals. Every other field is in the Reference.
Send rules
The exchange object has six groups, in the order that you decide them:
- Candidates:
exchange.candidates. Which models, creators and providers compete. - Rank:
exchange.rank. How the exchange ranks the offers that meet all limits. - Limits:
exchange.limits. An offer must meet each limit to compete. - Privacy, cache and session: data rules, caching rules and session rules.
cap_usd stays at the top of the object, and you can also send it in limits.
curl https://router.inference.ai.exchange/v1/chat/completions \
-H "Authorization: Bearer sk-liquid-..." \
-H "Content-Type: application/json" \
-d '{
"model": "architect.basket",
"messages": [{"role": "user", "content": "Say hello."}],
"exchange": {
"candidates": {"models": {"allow": ["gpt-oss-120b", "qwen3-*"]}},
"rank": {"strategy": {"kind": "price", "price_band_bps": 100}},
"cap_usd": 0.02
}
}'
The Anthropic SDK takes the same extra_body. An unknown field in exchange returns
400 invalid_request. So does allowed_models.
The groups
Each rule has a place in one of six groups.
| Group | Rules |
|---|---|
candidates |
models.allow, models.exclude, creators.allow, providers.allow, providers.exclude, providers.prefer |
rank |
strategy, cost_tier, fast, failure_risk_ranking, latency_nanos_per_ms, workload_profile, allow_single_supplier |
limits |
cap_usd, cap_nanos, price.input_usd_per_mtok, price.output_usd_per_mtok, price.cache_read_usd_per_mtok, price.cache_write_usd_per_mtok, price.unit_usd, ttft_ms, completion_ms, min_tps |
privacy |
retention, regions, requirements |
cache |
mode, expected_hit_rate, response, response_ttl_seconds |
session |
sticky, pin, margin |
{
"exchange": {
"candidates": { "providers": { "exclude": ["px_slow"] } },
"limits": { "cap_nanos": 50000000, "ttft_ms": 1500 }
}
}
cap_nanosis the cap as an integer of nano-USD.50000000equalscap_usd0.05. You can send it at the top of the object or inlimits.- If you set one rule twice with different values, the exchange returns
400 conflicting_parameters, and the message names both fields. It never picks one. session.stickyandsession.pinare two settings:stickyapplies to ranking by price, andpinapplies to Auto.- An unknown field in a group returns
400 invalid_request.
Models
model sets which models compete, and whether exchange.candidates.models.allow and exchange.rank.strategy can change that.
model |
Models that compete | exchange.candidates.models.allow |
exchange.rank.strategy |
|---|---|---|---|
A model, such as gpt-oss-120b |
That model | Refused: address_takes_no_basket |
price or fastest. Default price. |
A router model, such as liquid.auto |
The candidates of the router model | Limits the candidates | price, fastest or smartest; auto if the router model has a cap. Default: its own ranking. |
architect.basket |
The models in the basket | Required. Without it: basket_requires_models |
price, fastest, smartest or auto. Default price. |
| A preset | The saved models of the preset | Replaces the saved models | Replaces the saved strategy. See Presets. |
A strategy that model does not accept returns 400 strategy_conflicts_with_address. smartest for
one model returns 400 smartest_needs_several_models. To rank by speed or score through liquid.auto,
use a model-name suffix.
The basket
exchange.candidates.models.allow is a list. Each entry is one of:
- A served model name:
gpt-oss-120b. - A deployment symbol:
acme/gpt-oss-120b. It admits only that deployment. - A pattern with
*:gpt-*,*-mini. It admits each match when the request arrives.
[] admits each listed model, including a model listed later. If no entry matches a listed model, no
offer is admitted. The exchange removes repeated entries, then refuses a blank entry
(400 invalid_basket) and a router model or architect.basket as an entry (400 basket_names_router).
To choose the ranking, use exchange.rank.strategy.
{
"model": "architect.basket",
"messages": [{ "role": "user", "content": "Say hello." }],
"exchange": {
"candidates": { "models": { "allow": ["gpt-oss-120b", "gpt-oss-20b"] } },
"rank": { "strategy": { "kind": "auto", "preference": "balanced" } },
"cap_usd": 0.02
}
}
Strategy
exchange.rank.strategy ranks the offers that meet all limits.
price: the lowest cost wins. See Price and wait.auto: the best published score for the estimated charge wins. It needs a price bound: a cap, a cost tier other thanany, or an output-rate ceiling. See Auto Router.smartest: the highest published score wins, whatever the charge. It is Auto version 4 and needs a price bound.fastest: the highest median output speed that the exchange measured wins. An offer with no measurement ranks last. The estimated charge breaks a tie. See How the exchange measures performance.
{ "kind": "price", "price_band_bps": 0 }
{ "kind": "auto", "preference": "balanced", "version": "2", "price_band_bps": 0 }
{ "kind": "smartest" }
{ "kind": "fastest" }
Fields:
kind: required.smartestandfastesttake no other field.price_band_bps:priceandautoonly. Offers within this many basis points of the lowest cost count as one cost. 0 to 10000, default 0. A larger value returns400 invalid_price_band.preference:autoonly.economy,balancedorpremium.version:autoonly."1"to"8", default"8". Version 4 issmartest.
Fewest redos
Set exchange.rank.failure_risk_ranking to true to rank each offer at its estimated charge divided by the
chance of a good answer.
{ "exchange": { "rank": { "failure_risk_ranking": true } } }
The chance comes from the rates of the deployment over the last 7 days: failures that the provider caused, answers that came after the deadline, bad tool calls, and bad structured output. A late answer counts by how late it was, and the late rate has half the weight of a failure, because you got and paid for the answer. A deployment with no rate uses the rate of its model.
- The rule changes the order of the offers and nothing else. The price cap, the hold and the charge do not change.
- The chance of a redo counts at most 50%, so an offer ranks at no more than twice its charge.
- It applies to ranking by price and to Auto. The request value wins over the preset.
- A request with
response_formatofjson_objectorjson_schemauses the rule by default. Sendfalseto rank by charge alone.
Preferred providers
Set exchange.candidates.providers.prefer to try providers in order, then use the rest of the eligible market.
See Preferred providers.
Rank by one word
Add :smartest, :fastest or :cheapest to the model name to choose the ranking.
exchange.rank.strategy accepts the same words. See Axis routing.
Presets
A preset saves models, a strategy and limits under a name. Create one on the Presets page. Then send
the name as model:
{ "model": "fast", "messages": [ … ] }
A value in the request replaces the saved value:
exchange.candidates.models.allowreplaces the saved models.exchange.rank.strategyreplaces the whole saved strategy. A field that it omits gets its default.- Each limit that the request sends replaces the saved limit.
A preset belongs to one workspace. Its name cannot be architect.basket. Its models cannot include a
router model, architect.basket or another preset.
A preset with strategy auto needs a price bound. The bound can come from the request, the
x-liquid-cap-usd header, the preset or the key. The editor sets the cost tier high if you set no
bound.
The Presets page previews a sample request of 4,000 input and 1,000 output tokens. It buys nothing.
A preset buys one job kind: chat (default), image_generation, audio_speech or video_generation.
Set kind when you save. If a save omits it, the kind stays. Auto and token-based price or speed rules
apply only to chat, so the exchange refuses to save them on another kind. See
Modalities.
A preset from a receipt
On a receipt, select Save as preset. The editor starts from what the request asked for, not from the winning offer. A preset that copies the winner has one model at one provider, so each request fails when that provider pauses. Four stops set how much of the job the preset keeps:
- Exact replay: only the winning model and provider.
- Same model: the winning model, any provider.
- Your order: what the request asked for. This is the default.
- Open: models and strategy, no limits.
The preset keeps a limit that a router model added, such as the default cap of liquid.auto.
A preset linked to a key
A key linked to a preset limits each request with that key, for any model. The request cannot widen
those limits.
- Models. Only the preset's models can serve. A request that names some of them is limited to those.
A request that names none returns
403 key_policy_violation. An empty list sets no limit. - Limits. If both set a limit, the stricter applies: the lower
cap_usd,limits.ttft_ms,limits.completion_ms,limits.price.*andrank.cost_tier; the higherlimits.min_tps; andprivacy.retention: "none",rank.fast: trueandrank.allow_single_supplier: false. Excluded models and providers combine. An offer must passexchange.candidates.providers.allow,exchange.candidates.creators.allowandexchange.privacy.regionsof both. A differentexchange.rank.workload_profilereturns403 key_policy_violation. - Strategy. The key does not enforce it. A request that names the preset and sends no strategy uses the saved one.
Headers
A client that cannot change the body can send rules as headers. A header that cannot be read returns
400 invalid_header. No header sets models or ranking. To set them from headers only, use a
preset.
Header or body
Each purchasing rule has a field in the exchange block. Some rules also have a header. When both set the
same rule, one of them wins, as the table shows. In general the body wins: a header fills only a rule that
the body and the preset leave unset.
| Rule | Body field | Header | Which wins |
|---|---|---|---|
| Price cap | exchange.cap_usd |
x-liquid-cap-usd |
The body. |
| Time to first token | exchange.limits.ttft_ms |
x-liquid-ttft-ms |
The body. |
| Workload profile | exchange.rank.workload_profile |
x-liquid-profile |
The body. |
| Retention | exchange.privacy.retention |
x-liquid-retention |
The body. A preset linked to the key can make it stricter, never weaker. |
| Expected cache hit rate | exchange.cache.expected_hit_rate |
x-liquid-cache-hit-rate |
The body. |
| Response cache on or off | exchange.cache.response |
x-liquid-cache |
The body. response: false turns the cache off even if the header is true. |
| Response cache lifetime | exchange.cache.response_ttl_seconds |
x-liquid-cache-ttl |
The header. A header lifetime outside 1 to 86400 seconds is refused. A body lifetime outside it is moved into the range. |
| Clear the stored answer | None | x-liquid-cache-clear |
Only the header sets it. |
Any rule of the exchange block |
The field of the rule | x-liquid-rules |
The body, the preset and the single-rule headers, one rule at a time. A list in the header applies only if the body did not send that list. |
| Auto preference | exchange.rank.strategy.preference |
x-liquid-policy |
The body. The header fills a preference that no other field set. A price ranking ignores it. |
| Candidates, models, ranking | The fields of the groups | None | Only the body or a preset sets them. |
| Pin a job to one offer | None | x-liquid-pin-offer |
Only the header sets it. A key that cannot pin offers gets 403 pin_not_permitted. |
| Cost tag | None | x-liquid-tag |
Only the header sets it. See Cost tags. |
| Routing detail in the response | None | x-liquid-explain |
Only the header sets it. See Explain the choice. |
| Repeat a request safely | idempotency_key, on routes that move money |
idempotency-key |
The header and the body must agree, or the route refuses the request. |
A preset counts as part of the body for this table. A key's preset and the guardrails of the workspace limit both. See Presets.
The headers that need more words:
x-liquid-retention:noneorallowed.x-liquid-cache-hit-rate: 0 to 1. It is notx-liquid-cache, which switches the response cache.x-liquid-rules: a wholeexchangeblock as JSON. See Rules in one header.idempotency-key: a repeated request gets the first response. No second job runs.
Rules in one header
x-liquid-rules holds the exchange block as JSON. Use it for rules that have no header of their own:
x-liquid-rules: {"privacy":{"regions":["eu"]},"candidates":{"providers":{"exclude":["acme"]}}}
- It is the lowest layer. The body, the preset and the single-rule headers win over it, one rule at a time. A list in the header applies only if the body did not send that list and the preset left it empty.
- It cannot set
candidates.models.alloworrank.strategy. Use a preset. - A block that is not valid JSON, or has an unknown rule, returns
400 invalid_header. The header holds ASCII only.
Cost tags
Send x-liquid-tag to label what a call is for, such as a team or a project. A report can group the
spend by the label. The exchange never sends the tag to a provider. A tag has 1 to 63 characters, starts
with a letter or a digit, and holds letters, digits, dots, dashes and underscores. The exchange changes
upper case to lower case. A tag that does not fit returns 400 invalid_header, and the call does not
run.
Response
Each response has an exchange object. A stream sends it in the last chunk. It holds the job_id (also
the x-liquid-job-id header), the provider_slug, the price cap in charge.cap_usd, what you paid in
charge.total_usd, the metered usage, and timing.service: met if the provider kept its deadlines,
else missed. You still pay for a late job. Response fields has every field.
Explain the choice
Send x-liquid-explain: true to add a routing object to the exchange block. This example is
shortened.
{
"exchange": {
"job_id": "job_01k9x2",
"provider_slug": "acme",
"status": "settled",
"charge": { "cap_usd": 0.0134, "fee_usd": 0.000038, "total_usd": 0.000804 },
"timing": { "ttft_ms": 120, "completion_ms": 900, "service": "met" },
"routing": {
"clearing_rule": "first_price",
"offers_considered": 9,
"offers_eligible": 2,
"single_supplier": false,
"eligibility": {
"excluded": [
{ "provider": "beta", "reason": "service_too_slow", "count": 4 },
{ "provider": "gamma", "reason": "region_policy", "count": 3 }
]
}
}
}
}
Read a job later
GET /v1/jobs/{job_id} returns the same block for one job, with routing always included. A key with
read scope or higher can call it. A job of another workspace returns 404, the same as a job that
does not exist. The exchange writes the receipt just after it answers, so a call at once can return
404. Try again after a moment.
Refusals
No eligible offer returns 503 no_eligible_offer. If each offer only lacks a parameter of the request,
the error is 400 unsupported_parameter, and param names the parameter. eligibility.excluded has
one entry per considered offer with its reason. Reason codes are in Errors.
Reference
Cost
cap_usd: maximum charge for the job. An offer with a higher worst-case charge is not eligible.exchange.limits.price.input_usd_per_mtok,exchange.limits.price.output_usd_per_mtok: maximum published input and generation rates, USD per million tokens.exchange.limits.price.cache_read_usd_per_mtok,exchange.limits.price.cache_write_usd_per_mtok: the same for cache rates. An offer with no such rate passes.rank.cost_tier:any,low,medium,high,xhighormax. It overrides the tier of the router model.anyremoves the limit.
Exchange fee
The fee of a job is the larger of 5% of the supplier charge and a floor for each metered token.
charge.fee_usd shows it. See Fees.
- The floor of a kind is at most 1 cent per million tokens (0.1 cent for a cache read).
- An offer that charges nothing for a kind pays 5% of the model's list price or mean rate, when one exists, and at most that amount.
- A job on a free offer, such as
liquid.free, pays the floor. Its cap and hold are above zero. - A job priced by image, character or second pays no floor.
Service
limits.ttft_ms: maximum time to first token. The deadline adds 1 second per 10,000 input tokens, for the prompt read.limits.completion_ms: maximum time to completion.exchange.limits.min_tps: minimum generated tokens per second that the offer's deadline must give.rank.fast: a boolean for a stricter service threshold, with no numeric value.
Eligibility
exchange.candidates.models.exclude: names or patterns (gpt-*,*-mini) that must not serve the job. It wins over the basket.exchange.candidates.providers.allow: handles or patterns that can compete.[]and["*"]admit all.exchange.candidates.providers.prefer: handles or patterns to try first, in this order. It is not a limit. See Preferred providers.exchange.candidates.providers.exclude: handles that must not compete.exchange.candidates.creators.allow: creators whose models can compete. Case and known aliases match (meta-llama=meta,qwen=alibaba).exchange.privacy.regions: regions where the deployment can be.exchange.privacy.retention:noneorallowed. An offer that cannot obeynoneis not eligible.exchange.privacy.requirements:{ "require": [claim, ...], "checked_only": bool }. The provider must hold every claim. Withchecked_only, a declared claim does not pass. A lapsed claim never passes. See Provider claims.exchange.rank.workload_profile: a named profile. A saved workload has the profile<workspace>.<workload>@v<revision>. A profile of another workspace gets400 invalid_request.exchange.rank.allow_single_supplier: accept an award when only one independent supplier is eligible. Defaulttrue.
An empty list is a value. "candidates": {"providers": {"allow": []}} on a request clears the preset's rule.
Prompt caching
exchange.cache.mode:auto(default),buyeroroff. Who places prompt-cache breakpoints. See Prompt caching.exchange.cache.expected_hit_rate: 0 to 1. The share of input tokens that you expect to be billed at the cache read rate.
Expected cache hit rate
By default the exchange ranks offers as if no input were cached. For a job that reads most of its input
from a cache, an offer with a cheap input rate and an expensive cache read rate can rank above an offer
that costs you less. Set exchange.cache.expected_hit_rate, such as 0.99, and the exchange ranks each offer
with a cache read rate on the input that it expects to bill at that rate.
- The rule changes the order of the offers and nothing else. The cap, the hold and the charge do not change.
- An offer with no cache read rate, or with a cache read that costs no less than uncached input, gets no credit.
0turns the credit off. A value outside 0 to 1 returns a400that names the field.- The request value wins over the preset. It applies to ranking by price and to Auto.
- A rate higher than the share that the provider really reads can make you pay more than the best offer would have cost.
If you set no rate, the exchange can use a measured share, when its operator turns the setting on. It is the share of your workspace's input that repeated in the last 7 days, and it needs 50 settled jobs. Your rule wins over it.
When a rate applied, the exchange block holds cache_hit_rate:
expected: the share of input that ranking billed at the cache read rate.source:buyerormeasured.credit_usd: what the credit took offcharge.estimated_usd, the ranking estimate.real:usage.cached_tokensdivided byusage.prompt_tokens, once the job has usage. Arealrate far belowexpectedmeans that your rate is too high, or that the provider did not read its cache.
Response fields
Receipts describes each stored field, including charge.uncached_usd, created_at, charge.cache_usd, finish_reason and the media quantities. Fees explains charge.fee_usd.
job_id,provider_slug,offer_id: the job, the serving provider and the winning offer.status:pendinguntil usage settles, thensettledorcredited.charge.cap_usd: the price cap, locked before the first token.charge.total_usd: supplier charge plus fee, minus credits.charge.fee_usdandcharge.credits_usdare parts of it.charge.cap_held_usd: what the cap took off the metered charge, fee included. Present only when the job used more than its cap. The provider carries it.usage.prompt_tokens,cached_tokens,cache_write_tokens,completion_tokens: metered usage in the provider's tokenizer.prompt_tokensincludes cache reads and writes.usage.reasoning_tokens: hidden reasoning tokens, billed as output. Present only when the provider reports them.timing.ttft_ms,timing.completion_ms: measured time to first token and to completion. TTFT starts at request arrival, so it includes the auction.timing.ttft_limit_ms: your TTFT limit. Absent for image jobs and old receipts.timing.ttft_deadline_ms:ttft_limit_msplus 1 second per 10,000 input tokens. The job ismetormissedagainst this deadline, not against the provider's promise.timing.promised_ttft_ms: the time that the winning offer promised. Ametjob can be slower.timing.service:met,missed,failed,pending,unknown,not_startedorsingle_supplier_refused. A late job is charged like any other.addressing: the requested name, how it resolved, and the model and precision that served it.addressing.autorecords why Auto chose. See Auto Router.answer_checks.structured_output:resultand, forschema_mismatch, the failedkeyword. Present only withjson_objectorjson_schema. See Structured output.answer_checks.tool_calls,tool_call_errors,tool_call_faults,tool_markup: the tool calls, the bad ones, the bad ones by kind, and1when the text has tool-call markup but the answer has no tool call. Present only withtools. See Tool calls.buyer.client: the program that sent the request. See Client names.routing: present only withx-liquid-explain: true. See Explain the choice.
Client names
A receipt shows buyer.client only for a client that the exchange lists, such as claude-code or
codex. The exchange reads the user agent and X-Title to find it. It never shows the user agent,
Origin, Referer or an unlisted name, because that text can name a project or an internal host.
Router models
A request for liquid.auto admits each listed model in its cost tier, and no other router model. Router models have no versions, so liquid.auto@3 is not one.
GET /v1/exchange/routers lists each router model with its rules, its ranking (price or Auto) and the
models it admits now. exchange.candidates.models.allow limits the candidates to the basket. The router model's rules
still set each limit that the request omits. See Auto Router.
Older fields
These fields still work. Sending one with the field that replaced it returns 400 invalid_request.
exchange.policy: useexchange.rank.strategy.preference.exchange.approved_models: useexchange.candidates.models.allowwithmodelset toarchitect.basket.exchange.price_band_bps: useexchange.rank.strategy.price_band_bps.
exchange.allowed_models is removed and returns 400 invalid_request, because its patterns could admit a
deployment that a model list cannot name. Use exchange.candidates.models.allow.
OpenRouter provider
A client that targets OpenRouter sends a provider object. The exchange applies each setting that it can
apply exactly, and refuses every other setting. It never ignores one. A provider that is null or {}
asks for nothing. To move a whole integration, see Move from OpenRouter.
max_price.prompt,max_price.completion:exchange.limits.price.input_usd_per_mtokandexchange.limits.price.output_usd_per_mtok. A number or numeric string.only:exchange.candidates.providers.allow.order:exchange.candidates.providers.prefer. Withallow_fallbacks: false, it is alsoexchange.candidates.providers.allow, so only those providers compete.ignore:exchange.candidates.providers.exclude. A name must be a provider handle, or a pattern with*.data_collection: "deny":exchange.privacy.retentionbecomesnone."allow"changes nothing.zdr: true: requires thezero_retentionclaim inexchange.privacy.requirements.sort: "price":exchange.rank.strategybecomesprice. Refused when the strategy is another kind.require_parameters,allow_fallbacks: true: accepted. The exchange already matches on the features that the request needs.
The exchange refuses these with 400 unsupported_parameter, and param is provider: quantizations,
preferred_min_throughput, preferred_max_latency, max_price.request, max_price.image,
allow_fallbacks: false without order, any other sort, and any unknown setting. One answer lists
every refused setting. It also refuses a negative or non-numeric price and an ignore name that no
provider has.
provider can only narrow the limits. The exchange applies it after exchange and keeps the stricter
value of each field. It refuses the request if only, order with allow_fallbacks: false and
exchange.candidates.providers.allow share no provider, or if order and exchange.candidates.providers.prefer list
different providers. The preset of the key and the guardrails of the workspace limit provider as they
limit exchange. A field in provider replaces the value of a preset that you name as model.