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:
| Field | What it is |
|---|---|
| Protocol version | The wire version your node speaks. Clients drop nodes on an incompatible major version (see Health & compatibility). |
| Models | The catalog (below). |
| Built-in tools | Any in-loop tools your node offers, such as web search or image generation, with their names and descriptions. |
| Encryption recipient | Your 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:
| Field | What it is |
|---|---|
| Model id | The identifier users select and the one carried in requests. |
| Input and output modalities | E.g. text in / text out, or text+image in. Drives the Vision, Generation, and Edit capability badges. |
| Context window and maximum output length | The model's size limits. Set the output length explicitly — see Set an output ceiling. |
| Reasoning support | Whether the model supports reasoning, the effort levels it allows, and a default. Drives the Reasoning badge. |
| Tool support | Whether the model can call tools. Drives the Tools badge. |
| Tags | Descriptive 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. |
| Rates | Covered 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.
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.
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.
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.provider | What it points at |
|---|---|
openai_passthrough | Any OpenAI-compatible base_url — real OpenAI, an Anthropic OpenAI-compat endpoint, vLLM, LM Studio, Ollama, or a hosted gateway. |
lmstudio | A local LM Studio. Same chat path, plus LM Studio's /api/v1/models for runtime metadata. |
llamacpp | An externally-running llama-server. Same chat path, plus its /props endpoint for metadata. |
kronk | An externally-running Kronk server. Same chat path, plus its /v1/kronk/* endpoints for metadata and model provenance. Serves many models from one process. |
local | The node supervises a llama-server child itself over loopback. The recommended path for a self-hosted GPU. |
vertexai | Google 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. |
- openai_passthrough
- lmstudio / llamacpp
- kronk
- local
- vertexai
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).
These share the passthrough chat path but additionally query the runtime's native
metadata API, so /v1/zs/details carries context windows and modalities
without you hand-authoring every zs.models[].context:
lmstudio—base_urldefaults tohttp://localhost:1234/v1,api_keyoptional.translate_responses_to_chatdefaults to false (LM Studio implements/v1/responsesnatively).llamacpp—base_urldefaults tohttp://127.0.0.1:8080/v1,api_keyoptional.translate_responses_to_chatdefaults to true (llama-serverhas no/v1/responses).
Kronk is an OpenAI-compatible server that
links llama.cpp in-process and adds its own serving layer. Unlike
llama-server, it runs a real multi-model pool in one process — with a
memory budget, eviction, and an idle TTL — so it is the way to serve several
local models from a single node.
llm:
provider: "kronk"
openai:
base_url: "http://127.0.0.1:11435/v1" # the default
api_key: "" # optional; authorization mode defaults to open
translate_responses_to_chat defaults to false — Kronk implements
/v1/responses natively.
Why this provider exists. The node runs an always-on model identity gate:
a model is advertised only if it resolves to a canonical public artifact,
either through a zs.models[<id>].source you declare (hf:org/repo) or by
being a recognized frontier model. Kronk's /v1/models reports bare GGUF file
stems (Qwen3-8B-Q8_0), which satisfy neither — so under a plain
openai_passthrough every model is silently dropped from your advertised
catalog. The kronk provider reads Kronk's native endpoints and
reconstructs each model's HuggingFace ref from its models-directory layout
(<org>/<repo>/<file>.gguf → hf:<org>/<repo>@<revision>), so provenance,
context windows, and capabilities all resolve on their own. A source: you
declare in zs.models still wins.
zs-node doctor catches this one for you: it fingerprints the backend, and
pointing a non-kronk provider at a Kronk is reported as a failure, not a
note — along with exactly which of your models the backend does and doesn't
supply a source for.
Check the bind addresses before exposing the host. As of Kronk 1.30.3 both
listeners default to loopback (127.0.0.1:11435 for the API, 127.0.0.1:11445
for debug) — earlier versions bound 0.0.0.0, so verify rather than assume on
an upgraded box. The debug server serves /metrics and /debug/pprof/* with no
auth and no TLS, and an MCP listener (localhost:9000) plus a web admin UI are
both on by default.
If you deliberately need Kronk reachable off-box — a container, a LAN, a
Kubernetes service — set KRONK_WEB_API_HOST=0.0.0.0:11435 explicitly.
Publishing a container port is not sufficient when the process is bound to
the container's own loopback. Leave KRONK_WEB_DEBUG_HOST on loopback, or
firewall it.
Four more operational notes:
-
Never set
KRONK_INSECURE_LOGGING=trueon a serving node — it logs prompts, breaking the node's non-retention guarantee on your own box. -
Idle expiry is disabled by default as of Kronk 1.30.8. The effective
KRONK_POOL_TTLdefault is0; set a positive duration to unload idle models automatically. Count- and memory-pressure eviction still operate at zero. -
Pin
KRONK_LIB_VERSION. Kronk downloads its native llama.cpp libraries at runtime, so your inference stack can change under you without a node redeploy. Leave--allow-upgradeat itsfalsedefault. -
Mind
KRONK_AUTHORIZATION_MODE(which replacedKRONK_AUTH_ADMIN_ENABLED/KRONK_AUTH_LOCAL_ENABLEDin 1.30.3)./v1/kronk/*is Kronk's management API and needs an admin token in every mode exceptopen— the token that authenticates inference is not enough. Without one the node cannot read model provenance: chat keeps working, but you must declare asource:for every model by hand. Either runopen, or give the node an admin token.Two modes go further and will take the node dark if you miss them:
Mode /v1/modelsInference /v1/kronk/*openpublic public public managementpublic public admin token authenticatedany valid token any valid token admin token full-protectedany valid token token + endpoint grant admin token Under
authenticatedandfull-protected,/v1/modelsitself needs a token — and that list is how the node decides whether the backend is up. With no workingapi_keyit advertises zero models and refuses every reserve with a503while Kronk is perfectly healthy. Look for a401(missing or invalid token) or403(valid, but not admin) on the discovery probe in your node logs. Underfull-protected, inference tokens additionally need the matching endpoint grant (chat-completions,responses).
Kronk reports real cached-token counts, so a
cache_read_rate is supported on
this backend — but price it knowing the cache is per conversation, not
pool-wide. Kronk gives each conversation thread its own session, so the first
request of a new conversation reports cached_tokens: 0 even when an identical
system preamble is already warm from another conversation; from that thread's
second turn onward it reports ~99% reuse. Measured live at 1.30.3: a 1219-token
prompt went 0 → 1214 cached on repeat, while a sibling sharing the same preamble
started at 0 and only reached 1216 on its own second call.
In practice that still covers the dominant chat shape — every turn after the first — but it is not "any request sharing a system preamble," so don't size the discount as though a shared preamble were free across users.
Vision needs the projector pulled, not just a vision model. A multimodal
GGUF whose mmproj companion isn't on disk does not reject an image — it
silently answers from the text alone. The node therefore advertises image input
only when Kronk reports has_projection for that model; check with
GET /v1/kronk/models. If a vision model of yours is advertising text-only,
re-pull it so the projection lands.
One incompatibility to be aware of: Kronk rejects the OpenAI stop parameter
with a 400 on /v1/responses, which is the endpoint this provider defaults
to. Since 1.30.3 stop is supported on /v1/chat/completions (a string, or
up to four strings; the matched sequence is omitted from the response). The node
never sends it on either endpoint, but a caller that does will see the two
behave differently.
Kronk 1.30.4 added two more rejections in the same category — the node sends
neither, so they only reach a caller writing its own request body. n must be
1, null, or omitted; Kronk generates one choice per request, so multiple
samples need multiple requests. And an invalid grammar or response_format is
now a 400 instead of being ignored — earlier versions accepted the request and
generated unconstrained, so anything relying on that silently-lenient behavior
will start failing loudly.
The same release fixed a related annoyance: malformed-parameter errors that used
to return 500 now correctly return 400, so the node no longer spends its
transient-5xx retry budget on what is really a client mistake.
Check max_tokens in your model_config.yaml if you serve tool-using models.
When the output cap lands in the middle of a tool call, Kronk returns no tool
calls at all and finishes with finish_reason: "length". What the caller sees
in the answer depends on your Kronk version:
- 1.30.9 and later replace the half-written call with one sentence,
Response truncated before completion., on streaming and non-streaming alike. - 1.30.4 through 1.30.8 deliver the raw tool syntax as assistant text. The
node strips it on non-streaming requests, but on a streaming request it
cannot — those bytes have already been sent, so the caller sees the protocol
text, a
lengthfinish, and no tool result. Upgrade past 1.30.8 to be rid of it.
Either way the round yields no tool result and the caller is still charged. The
node never executes a call the model did not finish asking for, including the
announced-but-incomplete call a truncated stream can leave behind. Kronk's
shipped defaults cap max_tokens on its AGENT profiles (8192 from 1.30.4,
raised to 16384 in 1.31.x), so this is reachable on requests that would
otherwise run to the full context window. Raise the cap for models that make
long tool calls; the void_round disposition on zs_tool_call_leak_total
counts them.
Finally, a billing note for anyone upgrading Kronk: 1.30.3 counts reasoning,
control, and buffered tool-call tokens in completion_tokens even when those
bytes never surface as assistant text. Charges for reasoning and tool-calling
traffic will read higher than on earlier versions. The node bills what the
backend reports, so this is Kronk counting more completely, not a pricing
change.
The recommended path for a self-hosted GPU. The node starts, health-checks, and
restarts a llama-server child over loopback (5 attempts in 60s, then permanent
failure). Install llama-server once — there's no Docker image for this path
yet, so you install the binary yourself.
llm:
provider: "local"
local:
binary_path: "/usr/local/bin/llama-server" # macOS dev: /opt/homebrew/bin/llama-server
startup_timeout: "60s" # big GGUFs on cold storage may need more
host: "127.0.0.1" # loopback only; the node is the public face
port: 0 # 0 = OS picks an ephemeral port
parallel_slots: 0 # 0 = derive from zs.max_active_tickets
models:
- id: "qwen-2.5-0.5b"
model_path: "/models/Qwen2.5-0.5B-Instruct-Q4_K_M.gguf" # local GGUF
context_window: 32768 # per-session token budget
gpu_layers: 99 # 0 = CPU only; 99 = all layers on GPU (CUDA/Metal)
threads: 0 # 0 = llama-server default
extra_args: []
A few rules this path enforces:
- Single model per node by design. Declaring more than one
models[]entry is a config error. To serve several local GGUFs, run one node per model. - Don't set
--parallel/-np/--cont-batching/-c/--ctx-sizeinextra_args. Those are derived fromparallel_slotsandcontext_windowand are rejected at startup. The supervisor passes--cont-batchingautomatically when slots > 1. - KV cache scales with concurrency. It's roughly
context_window × parallel_slots × per_token_bytes, so raisingmax_active_tickets(whichparallel_slots: 0follows) raises memory and VRAM use. It often exceeds the weights — worked numbers for real models are in What the inference backend needs. - Local GGUF only — there's no Hugging Face pull yet.
Targets Vertex AI's OpenAI-compatible surface. There is no api_key field;
the node mints short-lived OAuth tokens from Application Default Credentials —
Cloud Run service identity, GKE Workload Identity, a GCE service account, or
gcloud auth application-default login for dev — and refreshes them
automatically. You declare the catalog yourself with the publisher-prefixed ids
Vertex expects, and /v1/responses is emulated for you.
llm:
provider: "vertexai"
vertexai:
project: "my-gcp-project"
location: "us-central1" # us-central1 has the broadest model availability
timeout: "0s" # 0 = disabled; required for long SSE streams
models:
- id: "google/gemini-2.5-flash"
context_window: 1048576
- id: "google/gemini-2.5-pro"
context_window: 2097152
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, oropenai_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, wherellama-serverloads exactly one. - One node per local model. Run several
localnodes, each with its ownconfig.yaml, its ownserver.listenport, and its ownzs.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/detailsadvertises zero models./v1/zs/reservereturns 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.
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 emptypricing: {}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_tokensis 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_rateandoutput_rateare required and each must be at least its base counterpart, and the highcache_read_ratemust be at or below the highinput_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_rateunset 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 highcache_read_rateat or above the highinput_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 basecache_read_rateis unaffected. - Raise
context_windowto the model's true maximum. The old workaround for the cliff was to capcontext_windowat the threshold so a single flat rate couldn't under-bill a bigger request (by rejecting it). Withlong_contextset 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_tokenshas to be strictly belowcontext_window, or the surcharge can never apply to a single request and you'd pay the higher upstream price while billing the lower one. (Omitcontext_windowand 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_windowthose 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
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 image —
image_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_tokensis rounded up to the next 1K (minimum 1K) when sizingmax_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) andalgo_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 atopen()- 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.
- 5 at
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 — andzs.models[id].max_active_ticketsoverrides it per model.reservereturns 429 when a model hits its cap.
Two timing settings are easy to confuse:
zs.ticket_ttl(default 5s) is the window betweenreserveand 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 402ticket_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 overrideexpires_after) is the settlement-complete deadline the contract reads to gaterefund_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.
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.