LLM inference

Pay 200 zat for each chunk of about 32 tokens from an open-weights model, and stop paying whenever you have enough text.

On this page

LLM inference is sold one chunk at a time. Each paid call returns the next chunk of a model's answer, about 32 tokens, so an agent that has enough text stops calling and stops paying.

The service has no model of its own. It streams from any OpenAI-compatible chat API: Ollama by default, a bundled llama.cpp server under Docker, or a hosted provider such as OpenRouter.

At a glance

PropertyValue
Paid routePOST /v1/generate
Price200 zat per call (Z402_PRICE_ZAT replaces it)
One paid call buysone output chunk of about 32 tokens (LLM_CHUNK_TOKENS)
Free routeGET /health
Default port4031
Providerthe operator
Ready whenthe model provider lists LLM_MODEL
Listed in the Bazaar as"LLM inference", tags llm, inference, text

How it works

POST prompt402 and the pricePOST prompt with a voucherVerifyValidStart generatingTokensSettle200 chunk 0 and a stream idPOST stream id with a voucher200 chunk 1AgentLLM serviceFacilitatorModelAgentLLM serviceFacilitatorModel
POSTprompt402 andthe pricePOSTpromptwith avoucherVerifyValidStart generatingTokensSettle200 chunk0 and astream idPOSTstream idwith avoucher200 chunk1AgentLLMserviceFacilitatorModelAgentLLMserviceFacilitatorModel
  1. Start. Send a prompt. The service checks the body before it asks for any money, so a bad request gets a 400 and costs nothing. A good one gets the 402.
  2. Pay and read. Retry with a voucherA message the agent signs for each call. It states the agent's running total, so the facilitator can check it against the deposit.. The service verifies it, starts the generation, waits for the first chunk, settles, and answers with chunk 0 and a stream id. Nothing is generated before this call is paid.
  3. Continue. Send { "stream": "<id>" } with a new voucher for each further chunk. The last chunk has done: true.

What a chunk is

The service reads the provider's stream and counts each non-empty piece of text as one token. A chunk is released when it holds LLM_CHUNK_TOKENS pieces and the next one has arrived, or when the provider's stream ends. That makes done exact: the service never sends a full chunk marked done: false and then an empty one. A generation with no output at all gives one empty final chunk.

Chunks are delivered in order, and a stream's position moves only after a paid call was delivered. If a call fails after its chunk was ready, the next call gets the same chunk.

The route

POST/v1/generate

Starts a stream, or returns the next chunk of one. Paid: 200 zat per call. An unpaid call gets a 402 with the offer in the PAYMENT-REQUIRED header. Send Content-Type: application/json.

Send prompt to start a stream, or stream to continue one, never both. Any other field is refused.

promptstring
Starts a stream. Required to start. 1 to 8,000 characters (an emoji counts as 2), not blank, valid Unicode (no lone surrogate).
systemstring
A system prompt for the model. Optional, only with prompt. Up to 8,000 characters, counted the same way.
maxTokensinteger
The most output tokens for the whole stream, 1 to 1,024 (LLM_MAX_TOKENS). Optional, only with prompt. Defaults to 1,024.
streamstring
The id from an earlier answer. Required to continue. Send it alone.

The answer is one chunk:

FieldTypeMeaning
streamstringThe stream id. Keep it to buy the next chunk.
indexintegerThe chunk number, from 0.
textstringThe chunk's text. Join the chunks in order. A chunk may start with a space.
donebooleantrue on the last chunk.
finishReasonstringOnly on the last chunk. stop when the model ended, length when maxTokens ended it, or what the provider reported.

See the offer

An unpaid call shows the price. The 402 body is {}. The offer is base64 JSON in the PAYMENT-REQUIRED header.

curl -si http://127.0.0.1:4031/v1/generate \
  -H 'content-type: application/json' \
  -d '{"prompt":"Explain shielded payments in three sentences.","maxTokens":96}'

The offer also carries the Bazaar's info (an example call) and a JSON Schema for the body under extensions.bazaar. See Protocol for every field. To read the header yourself, decode it:

Bash
curl -si http://127.0.0.1:4031/v1/generate -H 'content-type: application/json' -d '{"prompt":"hi"}' \
  | grep -i '^payment-required:' | cut -d' ' -f2 | base64 -d

Read a stream

These answers are illustrative. The stream id is random, and the text depends on the model.

{
  "stream": "jB8ajHfqaLW5NG0QGT0SeA",
  "index": 0,
  "text": "Shielded payments hide who pays whom and how much. For a software agent that matters, because a public ledger would show every purchase it makes",
  "done": false
}

Price and billing

One paid call is one chunk at 200 zat, so each token costs about 6 zat. Each call raises your voucher total by the price, and the vouchers draw down the deposit you made once for the window (see Prepaid vouchers).

Set maxTokens to what you need. It ends the stream at that many tokens, so it also caps the most a stream can cost:

maxTokensMost chunksMost cost at 200 zat
321200 zat
963600 zat
25681,600 zat
1,024326,400 zat

Stopping early costs nothing more. The service keeps generating in the background up to maxTokens, so stopping saves your vouchers and not the operator's compute. A stream with no call for LLM_STREAM_IDLE_MS (10 minutes) is dropped and its generation is stopped.

Limits and refusals

Everything the service can decide without calling the model is refused on your unpaid first attempt, before any voucher exists. A refused call is never charged.

StatuserrorWhen
400invalid_requestThe body is not JSON, was sent without Content-Type: application/json, has an unknown field, or breaks a rule above. message says which.
404unknown_streamThe id is unknown, or the stream was dropped after 10 idle minutes.
405method_not_allowedAny method but POST. The Allow header says POST.
409stream_busyAnother call on this stream is in flight. Streams take one paid call at a time.
409payment_in_flightThe same voucher is already paying for a call.
410stream_finishedEvery chunk was delivered. Start a new stream.
413body_too_largeThe body is over 100,096 bytes with the default limits.
502provider_failedThe stream's generation failed. Later calls on it are refused here, before payment.
503too_many_streamsLLM_MAX_STREAMS (64) streams are live.
503not_readyThe provider does not list LLM_MODEL.

Failures after payment

A few failures cannot be seen earlier. The facilitator does not settle them, but the stock client cannot tell a failed call from a paid one, so it counts the call and its next voucher pays for it.

StatuserrorWhen
502provider_failedThe model failed while the first chunk was being made.
504chunk_timeoutThe chunk was not ready in LLM_CHUNK_TIMEOUT_MS (60 s).
503too_many_streamsSeveral paid starts that each found room arrived together at the limit.
503stream_closedThe service is shutting down.

Make a paid call

The call buys one chunk at a time and stops when it has 200 characters. It needs a funded wallet and a running service: Make a paid call names the stack that paid-fetch.ts reaches, and Pay from an agent explains the setup.

import { close, paidFetch } from "./paid-fetch.js";

const url = "http://127.0.0.1:4031/v1/generate";

interface Chunk {
  stream: string;
  index: number;
  text: string;
  done: boolean;
  finishReason?: string;
}

async function next(body: { prompt: string; maxTokens?: number } | { stream: string }): Promise<Chunk> {
  const res = await paidFetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return (await res.json()) as Chunk;
}

try {
  // The first call opens the stream. Each later call buys the next chunk.
  let chunk = await next({ prompt: "Explain shielded payments in three sentences.", maxTokens: 96 });
  let text = chunk.text;
  while (!chunk.done && text.length < 200) {
    chunk = await next({ stream: chunk.stream });
    text += chunk.text;
  }
  console.log(text);
} finally {
  await close();
}

The first paid call also makes the deposit, so it can take tens of seconds. Later calls only sign a voucher. See Prepaid vouchers.

Privacy

  • The operator sees the prompt, the system prompt and the output. The model provider behind the service sees them too.
  • The facilitator sees the payment, not the request: the voucher, the price and the route's public URL. It never receives your prompt.
  • A stream id is 16 random bytes, base64url. It is the only thing that names a stream, so treat it as private for as long as the stream lives.
  • Under the default model, the prompt goes to a small open-weights model that the operator runs. With a hosted LLM_BASE_URL, it also goes to that host.

See Privacy model for what the payment itself reveals.

Run it yourself

The service reads services/llm/.env and the environment, and a variable set in the environment wins. An empty value counts as unset. A bad value stops the start with an llm: ... line that names it.

Bash
ollama pull qwen2.5:0.5b
cp services/llm/.env.example services/llm/.env   # set the facilitator URL and key, and Z402_PAY_TO
pnpm --filter @z402/service-llm start

The ready line goes to stdout. Logs go to stderr.

Text
llm listening on http://127.0.0.1:4031 model=qwen2.5:0.5b network=zcash:regtest

To use a hosted provider, change three lines:

services/llm/.env
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=your-provider-key
LLM_MODEL=qwen/qwen-2.5-7b-instruct

Settings

Shared by every service:

VariableDefaultMeaning
Z402_FACILITATOR_URLnoneRequired. The facilitator's URL.
Z402_PAY_TO or Z402_PAY_TO_FILEnoneRequired. The provider's shielded-only unified address. It must be the one the facilitator key belongs to.
Z402_FACILITATOR_TOKEN or _FILEnoneThe provider's API key, sent as a bearer token.
Z402_NETWORKregtestregtest, testnet or mainnet. Must match the facilitator.
Z402_PRICE_ZAT200Zat per chunk, from 1 to the facilitator's windowZat.
PUBLIC_URL, HOST, PORThttp://localhost:4031, 127.0.0.1, 4031The origin used in the 402 and in the Bazaar, and where the service listens.
Z402_FACILITATOR_WAIT_MS120000How long the service keeps retrying at start while the facilitator does not answer, 0 to 3,600,000. 0 fails at once.

Its own:

VariableDefaultMeaning
LLM_BASE_URLhttp://127.0.0.1:11434/v1OpenAI-compatible API root.
LLM_API_KEY or _FILEnoneSent to the provider as a bearer token.
LLM_MODELqwen2.5:0.5bThe model id. A name without a tag also matches name:latest.
LLM_CHUNK_TOKENS32Tokens per paid chunk, 1 to 512.
LLM_MAX_TOKENS1024Output cap per stream and the largest maxTokens, 1 to 32,768.
LLM_MAX_PROMPT_CHARS8000Longest prompt, and longest system prompt, in characters.
LLM_MAX_STREAMS64Streams live at once.
LLM_STREAM_IDLE_MS600000An idle stream is dropped and its generation stopped.
LLM_CHUNK_TIMEOUT_MS60000How long a paid call waits for its chunk, at most 60,000.

Under Docker, compose reads docker/env/llm.env first, then services/llm/.env, which wins. The Docker defaults point at the bundled llama.cpp server (qwen2.5-0.5b-instruct) and cap prompts at 1,600 characters so they fit its context. The first start downloads the model. See Deploy with Docker.

Readiness

The service is ready while the provider lists the model. It asks about every 10 seconds and logs each change. GET /health answers 200 when ready and 503 when not, and the service lists its route in the Bazaar only while it is ready. See Discovery.

JSON
{
  "ok": true,
  "service": "LLM inference",
  "ready": true,
  "network": "zcash:regtest",
  "priceZat": "200",
  "callsToday": 3,
  "routes": [{ "method": "POST", "path": "/v1/generate", "unit": "output chunk", "priceZat": "200" }]
}

callsToday counts paid calls delivered since 00:00 UTC. It is an aggregate and holds nothing per client.

Next steps

References

  • services/llm/src/service.ts: the route, its checks and the paid handler
  • services/llm/src/streams.ts: chunking, the stream registry and idle cleanup
  • services/llm/src/provider.ts: the OpenAI-compatible client and model readiness
  • services/llm/src/config.ts: the settings and their limits
  • services/llm/README.md: the route, refusals and settings
  • services/llm/.env.example: every variable with its default
  • packages/service-kit/src/service.ts: the kit that adds the 402, /health and the Bazaar declarations
  • docs/superpowers/specs/2026-10-27-z402-services-design.md: section 4.1, the service's contract
  • docker/env/llm.env: the Docker defaults
  • Ollama: OpenAI compatibility
  • llama.cpp server