# Quote over the session

An agent publishes prices over a WebSocket session. A quote is valid only while the session is open and the quote has not expired.

1. [Connect](#connect) and [log in](#log-in).
2. Read the `listed` frame. It names the deployments that you can quote.
3. [Send a `quote`](#send-a-quote) for each one. Read the `quote_ack` or `quote_reject`.
4. [Keep the session alive](#keep-the-session-alive). Quote again before each quote expires.

All frames are JSON objects with a `type` field. OpenAPI names them `ProviderMessage` and `ExchangeMessage`.

## Connect

```
wss://router.inference.ai.exchange/v1/provider/ws
```

The exchange closes the socket if `login` is more than five seconds late, or if any frame is 1 MiB or larger.

## Log in

The first frame must be `login`. For any other frame, the exchange sends `login_reject` and closes the connection.

```json
{
  "type": "login",
  "slug": "acme",
  "api_key": "sk-liquid-...",
  "dispatch_url": "https://provider.example.com/dispatch",
  "dispatch_token": "a-long-random-secret",
  "dispatch_dialect": "liquid",
  "capabilities": []
}
```

| Field              | Description                                                                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `slug`             | Your current handle. The exchange refuses a handle that you gave up.                                                                                               |
| `api_key`          | A provider key. See [Provider credentials](https://inference.ai.exchange/docs/provider.md).                                                                                                         |
| `dispatch_url`     | Where the exchange sends awarded jobs. Omit it with the `byok` dialect. The scheme, host and port must match the registered `declared_endpoint`. The path is free. |
| `dispatch_token`   | Optional. A secret that you choose. See [Dispatch token](#dispatch-token).                                                                                         |
| `dispatch_dialect` | Optional. `liquid` (default), `openai`, `anthropic` or `byok`. See [Dispatch dialect](#dispatch-dialect).                                                          |
| `capabilities`     | Optional. `handover` and `notice_ack`. The exchange ignores unknown values.                                                                                        |

### Dispatch token

The exchange sends the token as `Authorization: Bearer <token>` on each call to `dispatch_url` and to its `/content` route. Refuse a call without it, so that only the exchange can send you work.

The exchange keeps the token in memory for this session only. A new login replaces it. The reference agent reads it from `LIQUID_DISPATCH_TOKEN`.

### Dispatch dialect

| Value       | Effect                                                                                                                                  |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `liquid`    | The default. The exchange sends the native delivery.                                                                                    |
| `openai`    | `dispatch_url` is the base URL of an OpenAI-compatible API. `dispatch_token` is its key. See [OpenAI-compatible](https://inference.ai.exchange/docs/provider-openai.md). |
| `anthropic` | The same, for an Anthropic Messages API. See [Anthropic](https://inference.ai.exchange/docs/provider-anthropic.md).                                                      |
| `byok`      | For a provider that sells only [BYOK](https://inference.ai.exchange/docs/provider-byok.md) deployments. Give no `dispatch_url`.                                          |

### Capabilities

- `handover`: your agent can move to a new exchange process with no gap. See [Move to a new exchange process](#move-to-a-new-exchange-process).
- `notice_ack`: your agent acknowledges each notice. See [Acknowledge notices](#acknowledge-notices).

### Login result

After a successful login, the exchange sends `login_ack` and then `listed`:

```json
{ "type": "login_ack", "provider_id": "7c9e…", "slug": "acme", "server_time": "2026-09-21T10:00:00Z" }
{ "type": "listed", "models": [ … ] }
```

`login_ack` lists each accepted capability in `capabilities`.

A failed login gets `login_reject` with a `reason`, and the connection closes. For example, the key is unknown, the handle does not match the provider, or the `dispatch_url` differs from the declared endpoint.

### Two logins for one provider

A new login for the same provider replaces the current session and removes its offers. Run one agent for each provider, unless the agents use `handover`.

A login with `handover` keeps the offers of the current session instead:

- The older session keeps each offer, and gets its awards, until the newer session quotes it. Then the exchange sends `superseded` and closes the older session.

## Listed deployments

The exchange sends `listed` after login. Quote only the deployments in the newest `listed` frame. It has each `LISTED` deployment and each `UNLISTED` deployment (`"unverified": true`). It has no `SUSPENDED` or paused deployment. Only your workspace can buy an unverified offer.

`model` is the dispatch handle. `canonical_model` is the public name. See [Register deployments](https://inference.ai.exchange/docs/provider-models.md) for the two names.

```json
{
  "type": "listed",
  "models": [
    {
      "model_id": "3f0c…",
      "unverified": false,
      "deployment_name": "gpt-oss-120b",
      "model": "gpt-oss-120b",
      "max_input_tokens": 128000,
      "max_output_tokens": 4096,
      "tokenizer_id": "o200k_base",
      "markup_bps": 1500
    }
  ]
}
```

`markup_bps` is the margin from the console. It is absent when the console has no margin.

## Send a quote

Send one `quote` frame for each listed deployment. Send it again each time the price changes.

```json
{
  "type": "quote",
  "target": { "kind": "registered", "model_id": "3f0c…" },
  "pricing": [
    { "billable": "input_text", "unit": "mtok", "usd": 0.12 },
    { "billable": "cache_read", "unit": "mtok", "usd": 0.03 },
    { "billable": "output_text", "unit": "mtok", "usd": 0.48 }
  ],
  "fixed_fee": 0,
  "service_class": { "ttft_ms": 1500, "completion_ms": 30000 },
  "measured_p95_ttft_ms": 620,
  "supported_features": ["text", "tools", "input.image", "sampling.top_p"],
  "router_memberships": ["liquid.auto"],
  "retention_policy_supported": ["none"],
  "warm_prefixes": [],
  "synthetic": false,
  "revision": 7
}
```

Prices are JSON numbers in USD.

| Field                        | Type     | Req. | Description                                                                                                                                                                                                   |
| ---------------------------- | -------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target`                     | object   | Yes  | `{ "kind": "registered", "model_id": "…" }`.                                                                                                                                                                  |
| `pricing`                    | array    | No   | The prices of the quote, one line for each billable. Each line has `billable`, `unit`, `usd` and an optional `variant`. See [The price table](#the-price-table). Omit a billable if you do not charge for it. |
| `fixed_fee`                  | number   | No   | USD for each job. Default `0`.                                                                                                                                                                                |
| `service_class`              | object   | Yes  | `ttft_ms` and `completion_ms`: the deadlines that you agree to. If you miss one, the buyer still pays and the exchange marks your deployment slow.                                                            |
| `measured_p95_ttft_ms`       | integer  | No   | Your p95 time to first token. The exchange ranks on its own data.                                                                                                                                             |
| `supported_features`         | string[] | No   | The request parameters that the offer supports. See [Supported features](#supported-features).                                                                                                                |
| `router_memberships`         | string[] | No   | The router models that this offer competes under.                                                                                                                                                             |
| `workload_profile_targets`   | string[] | No   | The workload profiles that this offer targets.                                                                                                                                                                |
| `retention_policy_supported` | string[] | Yes  | `none`, `allowed`: the policies that you can obey. An offer with `none` also serves buyers who request `allowed`. A buyer who sets no rule needs `none`. The exchange rejects a quote that declares neither.  |
| `warm_prefixes`              | string[] | No   | Prefix digests that you agree to serve at the `cache_read` price, also if the cache removes the entry.                                                                                                        |
| `media_token_cost`           | object   | No   | Input tokens for each attachment: `image_base`, `image_per_tile`, `image_unmeasured`, `audio_per_second`, `pdf_per_page`, `video_per_second`. If absent, the exchange uses its own high table.                |
| `synthetic`                  | boolean  | No   | `true` when a local stand-in serves. A production exchange refuses it.                                                                                                                                        |
| `valid_for_ms`               | integer  | No   | How long the quote is valid, in milliseconds. Default: ten minutes. No upper limit. Zero is refused. `quote_ack` gives the accepted value.                                                                    |
| `revision`                   | integer  | Yes  | Must increase in a session. The exchange rejects a revision equal to or lower than the current one. A newer session with `handover` takes over an offer at any revision.                                      |

### The price table

A quote states its prices as a table in `pricing`. Each line is one price.

```json
"pricing": [
  {"billable": "input_text", "unit": "mtok", "usd": 0.25},
  {"billable": "output_text", "unit": "mtok", "usd": 1.0},
  {"billable": "output_video", "unit": "second", "usd": 0.08},
  {"billable": "output_video", "unit": "second", "variant": {"resolution": "720p", "audio": false}, "usd": 0.03}
]
```

- `billable` is what the price is for. Token prices: `input_text` (input that no cache served or kept), `cache_read`, `cache_write_short` (five minutes), `cache_write_long` (one hour), `output_text` and `output_audio` (audio output tokens, with `output.audio`). Counted prices: `output_image`, `output_speech`, `output_video`, `input_audio` (audio to transcribe), `input_reference_image`, `input_reference_video` and `input_reference_audio`.
- `unit` is the singular unit of the billable: `mtok` (a million tokens), `image`, `character` or `second`. A line with another unit is refused.
- `usd` is USD for one `unit`. For `mtok` it is USD per million tokens. A speech price of $15.00 per million characters is `0.000015`.
- `variant` holds `resolution`, `audio`, `quality` and `size`. A variant line holds for a request that states every option the variant names. A line with no variant is the price for a request that no variant line holds for. If two variant lines hold, the line that names more options wins.
  - `output_video` and the three `input_reference_*` billables take `resolution` and `audio`. See [Sound, resolution and price](https://inference.ai.exchange/docs/provider-models.md#sound-resolution-and-price).
  - `output_image` takes `quality` and `size`. The buyer states them as `quality` and `size` of the image request. For example, `{"quality": "hd"}` is the price of an image that the buyer asks for in `hd`.
  - `output_speech` takes `quality`. The buyer states it as `quality` of the speech request.

Each billable has one line with no variant. The exchange refuses these quotes with `invalid_terms` on the field `pricing`: a line with a variant on a billable that takes none, a line whose `unit` is not the unit of its billable, and two lines with the same billable and the same variant (also when the prices are equal). Do not send a second line with no variant. For token billables, `output_image` and `output_speech` the exchange refuses it. For `output_video` and the `input_reference_*` billables it keeps the later line, so send only one.

A quote changes only prices, deadlines and declared capabilities. The registration sets everything else.

`media_token_cost` sets the cap and the ranking. Settlement charges the counts that your upstream reports. If your value is too low, you pay the difference.

### Quote result

The exchange answers each quote with one `quote_ack` or `quote_reject`.

```json
{
  "type": "quote_ack",
  "offer_id": "3f0c…",
  "revision": 7,
  "activation_seq": 118,
  "valid_for_ms": 600000,
  "notes": [
    {
      "code": "defaulted",
      "field": "valid_for_ms",
      "detail": "absent: the offer rests for the default lifetime"
    }
  ]
}
{
  "type": "quote_reject",
  "model_id": "3f0c…",
  "reason": "…",
  "code": "paused"
}
```

`valid_for_ms` on the ack is a duration from now. Quote again well before it ends.

#### Notes

`notes` is absent when the offer rests as you quoted it and agrees with your claims. Each note is one change by the exchange, or one conflict:

- `defaulted` on `valid_for_ms`: you omitted the lifetime, so the offer rests for ten minutes.
- `defaulted` on `supported_features`: the exchange added `text` to a chat quote.
- `ignored`: the exchange does not know the field or value. It has no effect.
- `withheld`: the exchange removed a feature for a time, because requests that needed it failed.
- `overridden`: an operator set the features of this deployment. They replace yours.
- `conflict` on `retention_policy_supported`: the quote disagrees with the retention claim that you state on Data handling. The exchange changes nothing and applies the quote. You state `zero_retention`, but the quote has no `none`: buyers who set no retention rule cannot buy the offer. Or you state `retention_days`, but the quote has `none`. Correct the quote or the claim.

#### Rejection codes

Read `code` and `field` of `quote_reject`. Do not parse `reason`. `field` is absent when no single field is the cause.

| Code                      | Repair                                                                                               |
| ------------------------- | ---------------------------------------------------------------------------------------------------- |
| `malformed`               | The frame does not parse. `reason` names the field. Send the quote again.                            |
| `no_registration`         | Quote a deployment from the `listed` frame.                                                          |
| `paused`                  | Resume the deployment in the provider console, then quote again.                                     |
| `suspended`               | An operator must reinstate the deployment.                                                           |
| `catalogue_unavailable`   | Quote again later.                                                                                   |
| `wrong_target`            | Quote with `{ "kind": "registered", "model_id" }`.                                                   |
| `synthetic_on_production` | Quote the real model, or use a development exchange.                                                 |
| `invalid_terms`           | A value cannot rest. Repair the field in `field`.                                                    |
| `stale_revision`          | Send a `revision` higher than the one that rests.                                                    |
| `superseded`              | A newer session quotes this deployment. This session can re-mark only offers that it still holds.    |
| `contract_price`          | A reservation contract is linked to the deployment. Quote its input and output prices, no fixed fee. |
| `other`                   | A code this build does not know. Read `reason`.                                                      |

The exchange keeps the last refusal of each deployment until a quote rests. `GET /v1/provider/models` returns it as `quote_reject`, with `reason`, `first_rejected_at`, `last_rejected_at` and `count`. The Models page shows it too.

### Supported features

`supported_features` uses a fixed list of names. Declare only what your upstream supports. The exchange sends a parameter only to offers that declared it. The registered `input_modalities` also limit the media features ([Register deployments](https://inference.ai.exchange/docs/provider-models.md)).

| Feature                       | Request parameter                                                                                                                    |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `text`                        | Plain text. Each request includes it.                                                                                                |
| `tools`                       | `tools`                                                                                                                              |
| `response_format.json_object` | `response_format: {"type": "json_object"}`                                                                                           |
| `response_format.json_schema` | `response_format: {"type": "json_schema", …}`                                                                                        |
| `input.image`                 | `image_url` content parts, or Anthropic `image` blocks                                                                               |
| `input.audio`                 | `input_audio` content parts                                                                                                          |
| `input.file`                  | `file` content parts, or Anthropic `document` blocks                                                                                 |
| `input.video`                 | `video_url` content parts. Declare it only if the model reads video                                                                  |
| `sampling.<name>`             | `top_p`, `top_k`, `min_p`, `repetition_penalty`, `stop`, `seed`, `logprobs`, `logit_bias`, `frequency_penalty` or `presence_penalty` |
| `reasoning.effort`            | OpenAI `reasoning_effort`                                                                                                            |
| `reasoning.budget_tokens`     | Anthropic `thinking.budget_tokens`                                                                                                   |
| `output.audio`                | `modalities` with `"audio"`, and `audio`. Needs an `output_audio` line                                                               |
| `output.image`                | `modalities` with `"image"`. Needs an `output_text` line for image tokens                                                            |
| `image.seed`                  | `seed` of an image job                                                                                                               |
| `image.background`            | `background` of an image job: `transparent` or `opaque`                                                                              |
| `image.output_compression`    | `output_compression` of an image job                                                                                                 |
| `image.stream`                | `stream: true` of an image job: you send draft images before the final image                                                         |
| `speech.instructions`         | `instructions` of a speech job, for the clip or for a turn                                                                           |
| `speech.turns`                | `input` of a speech job as more than one turn, each with its own voice                                                               |
| `transcription.diarize`       | `diarize` of a transcription job: each segment names its `speaker`                                                                   |
| `transcription.keyterms`      | `keyterms` of a transcription job                                                                                                    |
| `embedding.input_type`        | `input_type` of an embeddings job                                                                                                    |
| `video.seed`                  | `seed` of a video job, a whole number of 0 or more                                                                                   |
| `video.creativity`            | `creativity` of a video job, a number from 0 to 1                                                                                    |
| `video.upscale_factor`        | `upscale_factor` of a video job, a whole number of 2 or more                                                                         |
| `video.continue`              | `previous_job_id` of a video job: the video continues an earlier job                                                                 |

- **Media controls.** Declare a media control only if your agent sends it to the upstream and the upstream honors it. A job with the control reaches only an offer that declared it. [Quote a media model](https://inference.ai.exchange/docs/provider-media-quote.md) has the rules.
- **Image output.** An offer has one `output_text` price. If image output tokens cost more, quote the higher price with `output.image`. Text-only requests then pay it too.
- **Reasoning levels.** If your upstream accepts only some levels, list them in `reasoning_efforts`, for example `["low", "high", "max"]`. The exchange sends the nearest listed level, and a tie goes to the higher one. With no list, it sends the buyer's level unchanged. The order is `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. If the upstream refuses a level and its error names the levels it accepts, the exchange remembers them for one hour and tries your offer again at the nearest level. If the error names none, or the upstream takes no level, the job goes to the next offer. If no other offer takes the job, the exchange tries your offer again with no level, and the receipt shows this.
- **Log probabilities.** `sampling.logprobs` means you must send them. See [Log probabilities](https://inference.ai.exchange/docs/provider-dispatch.md).

## Withdraw a quote

```json
{ "type": "withdraw_quote", "target": { "kind": "registered", "model_id": "3f0c…" } }
{ "type": "mass_withdraw" }
```

`withdraw_quote` removes one offer. `mass_withdraw` removes each offer of the provider, and the exchange confirms with a `risk_notice`. Neither frame cancels a job that the exchange already awarded. A quote has no capacity field. To stop awards when you are full, withdraw the offer or let it expire, and quote it again when you have room.

## Keep the session alive

If a session sends no frame for five seconds, the exchange closes it and removes its offers. Send a `heartbeat` when you have nothing else to send. The exchange replies with a `heartbeat`.

A heartbeat does not extend a quote. A quote ends after its `valid_for_ms`, even on an open session. A newer revision restores the offer.

When the session closes, the exchange removes its offers. Jobs already dispatched continue and settle.

## Reconnect

After you connect again, log in and send each quote again. Revisions start again in a new session.

After the exchange closes a session, connect again at once. After a failed connection, wait 0.25 seconds, then double the wait up to 5 seconds. After `superseded` with no other session, do not connect again.

The close frame has a code:

| Code         | Cause                                                                                                             | Next step                                         |
| ------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| 1012         | The exchange process stops.                                                                                       | Connect again at once.                            |
| 4000         | A login without `handover` replaced this session.                                                                 | Make sure that only one agent runs, then connect. |
| 4001         | A newer session holds all offers of this session.                                                                 | Do as for `superseded`.                           |
| 4002         | The exchange received no frame for five seconds.                                                                  | Connect again at once.                            |
| 4003         | The agent did not read its frames and the exchange queue filled.                                                  | Read the socket continuously, then connect.       |
| 4004 to 4006 | An operator revoked the key (4004), the provider is not admitted (4005), or its jurisdiction cannot trade (4006). | Wait, as after a failed connection.               |

With no close frame, a network fault or crash ended the session. Connect again at once.

## Acknowledge notices

The exchange sends a `settlement_notice` to the session that holds the offer. If that session closed, it goes to the current session. If no session is open, the notice is lost, unless you use `notice_ack`. Send `notice_ack` in `capabilities`. When `login_ack` has it, acknowledge each `award_notice` and `settlement_notice`:

```json
{ "type": "notice_ack", "award_id": "aw_…", "notice": "award" }
```

Set `notice` to `award` or `settlement`. After each login with `notice_ack`, the exchange sends the kept notices again, oldest first.

- A notice can arrive twice, or on another session. The award id and kind identify it.
- The exchange keeps a notice for 15 minutes, up to 4,096 for each provider, in memory. A crash deletes them. For a job with no notice, read the [jobs feed](https://inference.ai.exchange/docs/provider-dispatch.md#read-the-jobs-feed).
- A login without `notice_ack` deletes the kept notices.

## Move to a new exchange process

Before the old process of a release stops, the exchange sends `{ "type": "handover" }` to each session that logged in with `handover`. Then do these steps:

1. Keep this session open and quote on it.
2. Open a second session to the same address and log in with `handover`. It reaches the new exchange process.
3. Quote each deployment on the new session.
4. When the new session has a `quote_ack` or `quote_reject` for each quote, send `{ "type": "handover_ready" }` on the old session.
5. Serve jobs from both sessions. The old process closes the old session when it stops. Then use only the new session.

Without `handover`, your offers leave the book when the old process stops, until you connect and quote again.

A new version of your agent uses the same takeover. See [Upgrade the agent](https://inference.ai.exchange/docs/provider-operate.md#upgrade-the-agent).

### Requirements for the handover capability

Send `["handover"]` only when your agent does all of these:

1. Hold two sessions at once and share the award state between them.
2. Send a `heartbeat` on each session. Count `revision` from the start of each session.
3. On `handover`, follow the steps above. Never open a third session. Serve the jobs of the old session until the exchange closes it.
4. On `quote_reject` with `superseded`, stop quoting that deployment on that session, but keep the session alive. If it gets a new `listed` frame, it is current again. Quote each deployment on it.
5. On `superseded`, let the session close. If the agent has no other session, do not log in again. Finish the current dispatches, then exit. Another process holds the offers, and a new login takes them back.
6. On any other close with no other session, [reconnect](#reconnect). On shutdown, send `mass_withdraw` on each session, then finish the current dispatches.

If `login_ack` lacks the capability, the session works without `handover`. The reference provider does all of this. [Run your agent](https://inference.ai.exchange/docs/provider-operate.md#test-the-capability) has tests.

## Quote your cache prices

A buyer pays for input in up to four parts, each at its own rate (see the [fields](#send-a-quote)).

- `input_text`: no price means the offer has no input price.
- `cache_read`: without it, settlement refuses a job that reports a cache read and you are not paid. The ranking also assumes no cache saving. If your rate card has no cache-read price, quote the input rate here.
- `cache_write_short` and `cache_write_long`: without them, the exchange bills a written token as uncached input.

Quote a write rate only if your upstream charges for a write (Anthropic does, and OpenAI from GPT-5.6). Otherwise you carry any extra charge.

The cap covers a write only up to a cache breakpoint of the buyer. With no breakpoint, the exchange bills written tokens as uncached input, also if you quoted a write rate.

## Win sessions with your cache

In an agentic session, the buyer sends the same conversation on each turn. If you served the previous turn and quote a `cache_read` price, the exchange ranks your offer as if your cache serves part of the next turn. A higher list price can still win.

- The exchange measures your hit rate from settled jobs. Until then it assumes one half.
- The price cap is the cold worst case. To lower it, list the prefix digests that you guarantee in `warm_prefixes`. The cap then uses the `cache_read` price for them. You carry the risk if your upstream drops the entry.
- Keep one deployment for each model. A session stays with one deployment, so a second one does not share its cache.
- A session lasts five minutes after your last turn, or one hour with a one-hour breakpoint.
