Skip to main content

Serving models & pricing

Your node advertises everything it offers in a single public details document — the same one clients probe for health (see Health & compatibility). It lists your models, their capabilities, and your rates, and it carries your signed encryption recipient and protocol version. Clients read it directly to build the live catalog users see in the model picker.

This page describes what goes in that advertisement, how you wire a backend behind it, and how pricing works across the different request types.

The details document

Your details document is served unauthenticated and unencrypted at /v1/zs/details. At a high level it carries:

FieldWhat it is
Protocol versionThe wire version your node speaks. Clients drop nodes on an incompatible major version (see Health & compatibility).
ModelsThe catalog (below).
Built-in toolsAny in-loop tools your node offers, such as web search or image generation, with their names and descriptions.
Encryption recipientYour current sealing key, signed and with an expiry (see Encryption & keys).

Per-model advertisement

Each model in your catalog declares what it is and what it costs. The fields a client reads include:

FieldWhat it is
Model idThe identifier users select and the one carried in requests.
Input and output modalitiesE.g. text in / text out, or text+image in. Drives the Vision, Generation, and Edit capability badges.
Context window and maximum output lengthThe model's size limits. Set the output length explicitly — see Set an output ceiling.
Reasoning supportWhether the model supports reasoning, the effort levels it allows, and a default. Drives the Reasoning badge.
Tool supportWhether the model can call tools. Drives the Tools badge.
TagsDescriptive and policy tags (e.g. content-policy labels). Auto-populated from the model's HuggingFace card (when you declare a source), so you usually don't set them by hand; anything you add is merged in. These surface in the picker and can affect how Auto mode prioritizes the model.
RatesCovered next.

Advertise only what you actually serve. If you stop serving a model, remove it from the document — clients will route to anything you list, and a request to a model you no longer have is a failed request. The health check automates most of this for discovery-backed providers.

Set an output ceiling

max_output_tokens is worth setting by hand for every model you serve. It is the model's output ceiling: advertised to clients, enforced when a request reserves payment, and — because a declared ceiling is taken at face value — the number a client that doesn't ask for a specific one actually receives.

zs:
models:
"Qwen/Qwen2.5-32B-Instruct":
context:
context_window: 131072
max_output_tokens: 32768

Leave it out and clients fall back to a derived value: a quarter of the context window, capped at 32,768 tokens. Two things follow, and they pull in opposite directions.

  • Long answers get cut short. The derived cap applies however large your window is, so a 256K-context model still stops at 32,768 tokens.
  • Large explicit requests aren't caught. A client that does ask for more meets no ceiling on your node, so the request reaches your inference server and fails there — after the user's payment has already been reserved.

The second one is worth dwelling on, because it decides whether you see that traffic at all. When every operator serving a model declares a ceiling, the proxy lowers an over-large request to the largest one advertised — which leaves everyone who declared that much eligible to serve it, and drops everyone who declared less. Price and responsiveness still decide who among the eligible actually gets the request, so a generous ceiling buys you a place in the running, not the request itself. Declare a small one and you're not in the running at all. Declare none and nobody's ceiling applies — the request arrives at your backend at full size and fails there.

danger

max_output_tokens must be below context_window. A reservation covers input plus output, so a ceiling at or above the window leaves no room for a prompt: no request can fit, and your node is never selected for that model. It loads normally and advertises the model, so nothing looks wrong — you simply stop receiving traffic for it.

This applies to the window your node actually advertises. If you set a ceiling but no context_window, the discovered one from your backend is what clients see, and the same rule holds against it.

Not sure what to use? A quarter of the context window is a reasonable starting point, and it's what zs-node init suggests. The setup wizard asks about this for every model, and zs-node doctor flags a model that's missing a ceiling or has an impossible one.

Reasoning models

The Reasoning badge and the per-request effort selector only appear when you declare the capability — and for most self-hosted backends you have to, because they don't announce it. LM Studio publishes a reasoning capability automatically; vLLM, llama.cpp, and any OpenAI-compatible passthrough (including OpenAI's own o-series / gpt-5) publish none. Declare it under the model's context:

zs:
models:
google/gemma-4-31b-it:
context:
reasoning:
supported: true
allowed_efforts: ["low", "medium", "high"]
default_effort: "medium"

Advertising effort tiers is also what makes the client's toggle turn thinking on. A gemma-style template is off until the request carries an effort — with no reasoning knob at all it produces no trace by design. vLLM 0.25+ turns its enable_thinking flag on itself for any effort except "none", on both the Responses reasoning.effort and the Chat reasoning_effort, so once you advertise efforts the client's selection lights thinking up with no server-side change.

warning

A thinking model spends part of its output-token budget on the chain-of-thought before the visible answer, so on a hard prompt it can burn the whole budget thinking and return an empty, cut-off answer. The surest fix is to set an explicit output ceiling for the model.

info

Only if that isn't enough — a vLLM older than 0.25, or a template with a non-standard kwarg name — enable it on your inference server (vLLM's --default-chat-template-kwargs '{"enable_thinking": true}'). You don't need to worry about the field name — the node reads both vLLM's current reasoning field and the older reasoning_content automatically, and a backend's native /v1/responses reasoning items pass through untouched.

Choosing a backend

Your node doesn't run inference itself — it sits in front of an inference engine and speaks to it on loopback or over the network. You pick that engine with one setting, llm.provider. One provider per node. The choices:

llm.providerWhat it points at
openai_passthroughAny OpenAI-compatible base_url — real OpenAI, an Anthropic OpenAI-compat endpoint, vLLM, LM Studio, Ollama, or a hosted gateway.
lmstudioA local LM Studio. Same chat path, plus LM Studio's /api/v1/models for runtime metadata.
llamacppAn externally-running llama-server. Same chat path, plus its /props endpoint for metadata.
kronkAn externally-running Kronk server. Same chat path, plus its /v1/kronk/* endpoints for metadata and model provenance. Serves many models from one process.
localThe node supervises a llama-server child itself over loopback. The recommended path for a self-hosted GPU.
vertexaiGoogle Vertex AI's OpenAI-compatible surface (Gemini, gpt-oss MaaS). Authenticates via Application Default Credentials — no static key.
"" (empty)No text inference. Enables image-only or relay-only mode.

The most flexible option. Set the upstream's API root in llm.openai.base_url, and supply the key via the NODE_LLM_OPENAI_API_KEY env var rather than committing it:

llm:
provider: "openai_passthrough"
openai:
base_url: "https://api.openai.com/v1" # include the version segment yourself
api_key: "" # prefer NODE_LLM_OPENAI_API_KEY env var
timeout: "0s" # 0 = disabled; required for long streams
translate_responses_to_chat: false # true ONLY for upstreams without /v1/responses

base_url is appended to directly (/chat/completions, /responses, /models), so include the version segment — usually /v1 — yourself. For a non-/v1-rooted upstream, give the full prefix (e.g. https://api.z.ai/api/paas/v4). Point it at a local engine just as easily, e.g. vLLM at http://127.0.0.1:8000/v1 or Ollama at http://127.0.0.1:11434/v1.

Set translate_responses_to_chat: true when the upstream has no native /v1/responses — llama.cpp's own server and Ollama serve only chat completions, as does vLLM/TGI unless you've enabled its Responses endpoint; the node then emulates it on top of /v1/chat/completions. Leave it false for a backend that implements Responses natively (OpenAI, Azure OpenAI, LM Studio).

Serving many models

A single local node serves a single model, but a host can serve many in either of two ways:

  • Passthrough to a multi-model engine. Run Kronk, LM Studio, Ollama, or vLLM with several models loaded and point one node at it via kronk, lmstudio, or openai_passthrough. Discovery picks up everything the engine serves. Kronk is the closest fit if you want llama.cpp specifically: it pools many GGUFs in one process with a memory budget and eviction, where llama-server loads exactly one.
  • One node per local model. Run several local nodes, each with its own config.yaml, its own server.listen port, and its own zs.settlement_db_path. They can share the same operator identity and signing mnemonic.

Either way, the proxy routes each request by its model field across the operator directory, so users see one merged catalog.

Image generation

Image generation is configured independently of text via an image_llm backend, so a node can run text only, images only, or both. The provider is comfyui (self-hosted), comfyui_cloud, or openai_passthrough (a hosted OpenAI-compatible image API such as xAI):

image_llm:
provider: "comfyui"
comfyui:
base_url: "http://127.0.0.1:8000"
# data_dir: "/home/comfy/ComfyUI" # absolute; lets the node clean up generated files

data_dir is optional: set it to ComfyUI's base directory (the one with input/, output/, temp/) when the node can write there, and the node deletes the images it generated after each request. Leave it unset to clean up yourself — vanilla ComfyUI exposes no delete API. For comfyui_cloud, set comfyui_cloud.base_url and supply the key via NODE_LLM_COMFYUI_CLOUD_API_KEY.

For openai_passthrough, point openai.base_url at the upstream image API root and supply the key via NODE_IMAGE_LLM_OPENAI_API_KEY (never YAML). There are no workflow templates or render defaults — the request goes straight upstream:

image_llm:
provider: "openai_passthrough"
openai:
base_url: "https://api.x.ai/v1" # /images/generations, /images/edits appended

The zs-node init wizard configures this for you: point it at https://api.x.ai/v1 and it discovers each image model from /v1/models (via the image_price field), derives your per-image rate, and writes both the image_llm block and the per-model image: blocks. xAI backends must confirm zero data retention here exactly as on the text side (see below).

Each model that serves images declares an image: block in zs.models[], selecting the backend, its per-image rate, a max_n ceiling, default render parameters, and a built-in workflow template (template_internal) or a custom template_path:

zs:
models:
sdxl-base:
image:
backend: "comfyui"
image_rate: 0.05 # USD per 1024²-standard image, size-aware (see below)
max_n: 4
defaults: {width: 1024, height: 1024, steps: 20, cfg: 7.0}
comfyui:
template_internal: "sdxl-1024" # or template_path: ".../workflow.json"

An openai_passthrough image model is simpler — no templates or render defaults, just the backend and its rates (image_rate for generation, image_edit_rate for edits; declare at least one):

zs:
models:
"grok-2-image":
image:
backend: "openai_passthrough"
image_rate: 0.084 # USD per image (size-aware, see below)
image_edit_rate: 0.084 # omit to not serve /v1/images/edits

Image-only mode is automatic: omit the llm: block (or set llm.provider: "") while image_llm.provider is set. Then /v1/chat/completions and /v1/responses return 404 text_inference_not_supported, and /v1/models and /v1/zs/details advertise only your image models.

The health check gates what you advertise

The node continuously probes its backend so it never advertises a model the engine can't actually serve. The probe hits the provider's discovery endpoint — GET /v1/models for openai_passthrough, llamacpp, and kronk, and LM Studio's native /api/v1/models for lmstudio — on a cadence you can tune:

llm:
health_check:
interval: "30s" # probe cadence
timeout: "5s" # per-probe deadline; must be < interval
failure_threshold: 2 # consecutive failures before the backend is marked down

When the probe is healthy, /v1/zs/details advertises exactly the models the backend serves, intersected with your zs.models / default_pricing. After failure_threshold consecutive failures the backend is marked down:

  • /v1/zs/details advertises zero models.
  • /v1/zs/reserve returns 503 (provider_unavailable) until a probe succeeds again.

This is the "I shut down vLLM and the node kept taking reservations" case, closed. The local and vertexai providers derive their model list from config and are always reported available, since there's no separate engine to lose. Two metrics expose the state on the private listener: zs_provider_healthy (1/0) and zs_provider_discovered_models.

xAI backends must confirm zero data retention

If your base_url points at xAI (api.x.ai), the same gate additionally requires Zero Data Retention — automatically and with no opt-out. xAI retains API traffic for 30 days by default; the only way to disable that is the org-level ZDR toggle in the xAI console (Team Settings → Zero Data Retention), after which xAI confirms it on every response.

Enforcement is fail-fast rather than degraded. The node's startup usage probe is a real inference, so the ZDR guard sees its response: a node whose xAI team has ZDR switched off does not start — it exits with startup usage-reporting probe failed. There is no periodic ZDR probe and no "advertises zero models until confirmed" state. If ZDR is switched off under an already-running node, each prompt-carrying request is refused individually, before any stream frame is emitted, at no charge to the payer. Enable ZDR for your xAI team before serving. (This is separate from store, which does not affect xAI's audit retention.)

The same requirement applies to the openai_passthrough image backend when its base_url is xAI: every image response must carry the ZDR confirmation, or the node refuses it at no charge (403). ZDR is an account-wide xAI setting, so a text xAI backend's startup probe already fail-boots a non-ZDR account; the image path relies on this per-request guard rather than a separate boot probe (which would cost a real image every start).

Pricing the routes

There are three priced request types, and a model can offer any combination. You set the rate for each.

info

Beyond these token/image rates, a request can also run tool calls that cost you — a frontier vendor's server-side web search, or your own zs_ built-ins. Charge for those with Per-call tool pricing.

Text (token-based)

The primary route. You set an input rate and an output rate, each in USD per million tokens — the unit vendors quote, so you can paste values straight off a price card. The node converts each to microUSDC at reserve as ceil(usd_per_1m × 1_000_000) (USDC has 6 decimals and is dollar-pegged) and pins the result into the signed ticket as the USDC escrow amount.

You set rates in one of two places (or both):

  • zs.default_pricing — a fleet-wide default. With it set, any model the provider discovers is auto-eligible for reserve; an operator running "$X/1M for everything" needs nothing more.
  • A per-model entry under zs.models[id].pricing — overrides the default for that model. An omitted or empty pricing: {} inherits the default; an explicit {input_rate: 0, output_rate: 0} is a free model that does not inherit.

Discounting cached input (cache_read_rate)

Many upstreams serve part of a repeated prompt from a prefix/prompt cache and report that subset as a "cached" token count. You can pass that saving through to the user with an optional third rate on any pricing block:

zs:
default_pricing:
input_rate: 0.15
output_rate: 0.60
cache_read_rate: 0.0375 # ~25% of input_rate — the cached-read discount

cache_read_rate (USD per 1M tokens) is billed on the cached-read subset of the input instead of input_rate; the rest of the input still bills at input_rate. It is optional and safe: omit it and cached reads bill at input_rate (no discount), 0 makes them free, and any value up to input_rate is a discount that only ever lowers the final charge (the escrowed maximum price is unaffected).

When you set a real discount, the node also advertises it on /v1/zs/details (as cache_read_rate_usd_per_1m), so clients can show the lower cached rate before a request — the chat app surfaces it as a "Cached input" row in the per-model pricing detail. A model you don't discount advertises no cache rate (nothing to show); a 0 advertises as free cached reads.

The catch: the discount only takes effect when your upstream actually reports a cached count. OpenAI, z.ai/GLM, SGLang, DeepSeek, Vertex/Gemini, and a local llama.cpp all report one; vLLM only includes it when started with --enable-prompt-tokens-details (off by default); and LM Studio / Ollama don't report it at all, so the rate is simply a no-op there. Confirm what your backend reports before relying on it — set llm.openai.log_raw_usage: true for a capture run and watch the raw upstream usage log line. See the node operator guide's "Cached-token pricing" section for the full per-provider table.

Long-context surcharge (long_context)

Some upstreams — xAI/Grok most notably — price with a context-size cliff: below a prompt-token threshold you pay one rate, and at/above it you pay a higher rate for every token in the request (input, cached, and output all step up). Declare it with a long_context block on any pricing (or default_pricing) so a large-context request bills correctly instead of being turned away:

zs:
models:
grok-4.5:
pricing:
input_rate: 2.0 # base (below-threshold) tier
output_rate: 6.0
cache_read_rate: 0.30
long_context:
threshold_tokens: 200000 # prompt tokens at/above which the high tier applies
input_rate: 4.0 # high rates — each must be >= its base counterpart
output_rate: 12.0
cache_read_rate: 0.60 # optional — omitted, the base discount is carried forward
context:
context_window: 256000 # the model's TRUE max — see below

A few things to know:

  • The tier is decided by the prompt (input) tokens alone. A request whose reserved input reaches threshold_tokens is billed at the high rates for the whole request; output tokens bill at the high output rate but never decide the tier. The node resolves this once at reserve and pins the tier-appropriate rates onto the ticket, so the user's app agrees on the price up front.
  • The high rates must be a surcharge, not a discount. input_rate and output_rate are required and each must be at least its base counterpart, and the high cache_read_rate must be at or below the high input_rate — the same rule the base pair follows. The node rejects an inconsistent tier (or a tier on a fully-free model) at startup.
  • A base cached-read discount carries into the high tier by itself. Leave the high cache_read_rate unset and the node scales your base discount by the same step-up the input rate takes — 0.30 × (4.0 / 2.0) = 0.60, exactly what xAI charges. Set it explicitly if your upstream differs. What you can't do is explicitly declare no high-tier discount (a high cache_read_rate at or above the high input_rate) while the base tier has one — the node rejects that at startup, because a rate is only published when it's a real discount on its own tier, so an undiscounted high rate is never advertised, and a user still running an older build will check your high-tier price against the base discount, decide it's overpriced, and quietly route around you. A model with no base cache_read_rate is unaffected.
  • Raise context_window to the model's true maximum. The old workaround for the cliff was to cap context_window at the threshold so a single flat rate couldn't under-bill a bigger request (by rejecting it). With long_context set you no longer need that cap — a request above the threshold is admitted and billed at the high tier. Leaving the old cap in place is a startup error: threshold_tokens has to be strictly below context_window, or the surcharge can never apply to a single request and you'd pay the higher upstream price while billing the lower one. (Omit context_window and the check doesn't apply — the backend's own limit is used at runtime.)
  • A large reserve doesn't mean a large bill. The amount a request reserves is a worst case, not a measurement: apps add headroom for tool loops, and a multi-turn session that keeps its history on the server has to reserve the whole window because your node can't see that history. With a big context_window those requests reserve above the threshold however short the real prompt is. So the node re-checks the tier when it bills, against the prompt that was actually sent, and charges the base rates when it never reached the threshold. It only ever adjusts downward — a request that did cross the threshold on a base-priced ticket still bills at the base rate.
  • It's advertised. The threshold and high rates ride /v1/zs/details, so the chat app previews the higher price for a long prompt and shows an "Above <threshold>" row in the model's pricing detail. Advisory, like the rest of the rate card — the authoritative price is still the one pinned into the ticket.

All of the above is about token pricing. A dedicated image model (one with an image: block) produces no tokens, so it needs no pricing entry — its image_rate / image_edit_rate is the whole basis. It also never inherits default_pricing, so you can leave that set for your chat models without it leaking a per-token price onto an image model.

The per-request minimum charge treats an image model as paid whenever its image rate is above zero. Only the network-fee (min_charge.algo_txns) component applies — the token component is skipped, since the request produces no tokens to floor.

Which rate applies to a given model:

zs:
default_pricing:
input_rate: 0.15 # USD per 1M input tokens
output_rate: 0.60 # USD per 1M output tokens

models:
gpt-4o-mini:
pricing:
input_rate: 0.15
output_rate: 0.60
context:
context_window: 128000
max_output_tokens: 16384
input_modalities: ["text"]
output_modalities: ["text"]
gpt-4o:
pricing: {} # inherits default_pricing
context:
context_window: 128000
max_output_tokens: 16384
input_modalities: ["text", "image"] # vision-capable
output_modalities: ["text"]
max_active_tickets: 4 # tighter per-model concurrency cap
info

Your published rates are net of the protocol fee — set them to what you want to keep. The protocol fee (the escrow contract's protocolFeeBps) is added on top and paid by the payer; the node grosses up the escrowed max_price at reserve to cover it. Do not inflate your rates to absorb the fee. See Staking & economics.

Dedicated image routes

For models that generate or edit images directly, you price per imageimage_rate for generation and image_edit_rate for edits (each USD per 1024²-standard image). The rate is size-aware: the charge is rate × (w × h) / 1024² × quality_multiplier, where the quality multiplier is ×0.25 (low), ×1.0 (medium / standard), or ×4.0 (high / hd). Generation and editing are declared per model, so a model can offer one route without the other.

In-loop image tools

A text model can produce images mid-response via a built-in image tool. These are priced per image as a tool output, separately from the dedicated image routes — the dedicated routes are model-to-image, while the tool rate is for images produced inside a text turn. A positive tool rate is what marks the tool as served.

How rates become the price a user sees

You advertise your charge. The price a user agrees to is your charge plus the protocol fee, presented all-in — so the rate shown in the picker is the rate the user pays. When a request runs:

  • The reserve ticket's maximum is sized from your rates and the request's budget so it covers the worst case. For text, max_output_tokens is rounded up to the next 1K (minimum 1K) when sizing max_price; input tokens are sized exactly.
  • The receipt's actual charge reflects what was really consumed, within that ceiling. Input is billed exactly per token; output is billed per token with a 1000-token floor (see the minimum charge below).
  • Unused microUSDC is refunded to the user automatically via the escrow contract at settlement.

Because the ceiling protects you and the refund protects the user, you can quote generously without overcharging anyone.

Minimum charge, context, and concurrency

A few zs settings round out a model's commercial terms:

  • zs.min_charge — a per-request floor so a tiny successful response never settles for ~0. It's the max of two components: output_tokens (token floor, default 1000 — bills as if at least this many output tokens were produced at the ticket's output rate) and algo_txns (µALGO floor in units of 1000 µALGO, default 0; 7 is recommended to recover the ~7,000 µALGO of network fees you absorb per paid request on a live deployment — 2 at open()
    • 5 at settle(); a free model or a failed request settles for less — converted via the ALGO/USD oracle). Free models bypass the floor, and the µALGO component is skipped while the oracle is unavailable.
  • zs.models[id].context — a defense-in-depth ceiling enforced at reserve time. The proxy is the primary sizing gatekeeper; this catches a misconfigured proxy or a client that bypasses it. Omitting it means "unbounded for this model".
  • zs.max_active_tickets (default 16) — the per-model concurrency cap. Each model has its own independent pool — there is no shared cross-model ceiling — and zs.models[id].max_active_tickets overrides it per model. reserve returns 429 when a model hits its cap.

Two timing settings are easy to confuse:

  • zs.ticket_ttl (default 5s) is the window between reserve and the inference POST — the client/proxy round-trip only. It does not bound how long inference or streaming runs; a 5-minute stream on a 5s-TTL ticket completes normally. A POST that arrives after expiry gets HTTP 402 ticket_invalid. Keeping it tight means an abandoned reservation frees its slot in seconds instead of squatting capacity.
  • zs.default_expires_after (default 5m, per-model override expires_after) is the settlement-complete deadline the contract reads to gate refund_inactive. The happy-path co-signed settle still fires seconds after delivery; this only widens the window before an abandoned ticket becomes refundable, so slower or reasoning models settle without per-model tuning.

Billing what you serve

Your receipt reports the actual counts you bill on:

  • Input and output token counts for text.
  • Images produced, for image routes and in-loop image tools.

The receipt is signed and bound to the response body the user received (see The payment flow), so your billed counts have to correspond to what you actually delivered. Count honestly — a receipt the client can't reconcile against the response it got won't settle.

info

The exact JSON shape of the details document and the receipt — field names, encodings, and units — is part of the wire protocol your node software implements, and a full protocol specification is coming soon. This page covers what you advertise and how it's priced, not the byte-level format.