OpenAI-compatible API.
/v1/tenzro/…. Point a client's base URL at a Tenzro node.- STATUS
- Testnet
- CRATE
- tenzro-node
- STABILITY
- Stable
- TYPE
- HTTP Surface
Routes
The node serves an OpenAI-compatible HTTP surface for clients and aggregators that speak the OpenAI wire format.
GET /v1/models every model this gateway can serve
GET /v1/models/{id} one model by instance ID or model ID
POST /v1/chat/completions chat completions, streaming and non-streaming
POST /v1/responses the Responses shape over the same handler
POST /api/paid/chat/completions same handler behind the HTTP 402 payment gate
POST /v1/embeddings text embeddings in the OpenAI wire shape
POST /v1/audio/transcriptions speech recognition over the ASR runtimes
POST /v1/images/generations text-to-image over the media-generation queue
POST /v1/images/edits image-to-image, multipart
POST /v1/videos video rendering, multipart, returns a job
GET /v1/videos/{id} one video job's status
GET /v1/videos/{id}/content a finished clip's bytes
POST /v1/tenzro/forecasts timeseries forecasting
POST /v1/tenzro/detections object detection
POST /v1/tenzro/segmentations promptable segmentation
POST /v1/tenzro/video/embeddings clip embedding
GET /v1/generation recorded stats for one completion idListing metadata derives from registry and gossip-announcement state — providers do not maintain a separate listing configuration. /v1/embeddings is served by any loaded ONNX encoder, local or a network provider; input is a string or an array, and dimensions requests Matryoshka truncation on models that support it (EmbeddingGemma, Qwen3-Embedding).
Modality matrix
Every modality this node serves has a slot on the surface, and which slot it takes follows one rule: if the vendor publishes a path for the modality, it is served at that path in that shape. If none exists, it is served under /v1/tenzro/…. A caller reaching a new modality writes against a path it can predict, and a modality added later has an obvious slot rather than needing a bespoke client.
MODALITY ENDPOINT BODY RUNTIME
text generation POST /v1/chat/completions JSON ModelRuntime
POST /v1/responses JSON ModelRuntime
text embedding POST /v1/embeddings JSON TextEmbeddingRuntime
speech recognition POST /v1/audio/transcriptions multipart AudioRuntime
text to image POST /v1/images/generations JSON media-gen queue
image to image POST /v1/images/edits multipart media-gen queue
text/image to video POST /v1/videos multipart media-gen queue
timeseries forecast POST /v1/tenzro/forecasts JSON TimeseriesRuntime
object detection POST /v1/tenzro/detections JSON DetectionRuntime
segmentation POST /v1/tenzro/segmentations JSON SegmentationRuntime
TextSegmentationRuntime
clip embedding POST /v1/tenzro/video/embeddings JSON VideoRuntimeThe namespace split is a forward-compatibility measure, not a quality distinction. Naming a bare /v1/detections would collide with the vendor's own path the day they publish one, and a caller written against ours would break on an upgrade. /v1/tenzro/… cannot collide, so both can be served side by side.
The body shape follows the vendor path it mirrors. /v1/images/edits and /v1/videos are multipart/form-data because their published shapes are, and the four Tenzro paths are JSON because they carry no file upload the vendor has fixed the encoding of. On those, media travels base64 inside the JSON body — image_base64, video_base64, and masks on the way back — and the route's body ceiling is 64 MiB rather than the 2 MiB that governs an ordinary JSON body.
Image embedding has no route of its own. An image reaches a vision encoder as an image_url content part on a chat message, and the similarity read is tenzro_imageTextSimilarity over JSON-RPC — a similarity score between two artifacts is not a completion, an embedding list, or a rendered file, so no vendor shape holds it.
/v1/tenzro/video/embeddings is served, but no model can currently be loaded behind it: the catalog advertises the V-JEPA 2 family and the upstream facebook/vjepa2-* repositories carry safetensors only, with no ONNX export, so tenzro_loadVideoModel refuses them. Registering a loaded image encoder as a frame-wise fallback is the working path until an export is published. A request against an unloaded model returns 400 model_not_loaded naming the RPC that loads one.
Responses endpoint
POST /v1/responses serves the Responses shape by rewriting the request into a chat body, handing it to the same handler that serves /v1/chat/completions, and rewriting the result back. Offer resolution, provider pinning, streaming failover, settlement, provenance signing, the jurisdiction receipt and cursor resume behave identically on both routes — this endpoint adds a vocabulary, not a second execution path.
model string, instance ID or model ID from /v1/models
input string or array, becomes messages
instructions string, becomes a leading system message
max_output_tokens uint, becomes max_tokens
temperature float
top_p float
stream bool, emits the typed event sequence below
metadata object, echoed on the response, not forwarded
store bool, accepted and always reported back as falseEvery field the Responses schema does not name reaches the chat body untouched, so the sampling fields (top_k, min_p, stop, the penalties, stream_options, user) and the Tenzro extensions (models, provider, seed, jurisdiction, jurisdiction_receipt, draft_n, verifiable) work here exactly as they do on /v1/chat/completions. A blank or whitespace-only instructions is dropped rather than sent as an empty turn.
Each input item is a message: an optional type that must be message, an optional role defaulting to user, and content as a string or an array of parts. The two surfaces name the same parts differently:
input_text -> text text. output_text and text are also read.
input_image -> image_url image_url as a bare https:// or data: URL string,
plus a sibling detail. An object-valued image_url
is also read. A file_id image is refused.
input_file -> file file_id, or file_data + filename. Flattened at the
part level here, nested under file in the chat shape.
input_audio -> input_audio data (base64) and format. Same shape on both.The response object carries the Responses fields plus the same Tenzro extensions the chat body carries:
{
"id": "resp_9f3c…",
"object": "response",
"created_at": 1780560000,
"status": "completed",
"model": "qwen3-8b",
"output": [{
"id": "msg_9f3c…",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{ "type": "output_text", "text": "Hello.", "annotations": [] }]
}],
"output_text": "Hello.",
"instructions": null,
"max_output_tokens": null,
"temperature": 0.7,
"top_p": 0.9,
"metadata": {},
"store": false,
"incomplete_details": null,
"error": null,
"usage": { "input_tokens": 12, "output_tokens": 48, "total_tokens": 60 },
"native_finish_reason": "eos",
"cost_wei": "156000000000000",
"generation_time_ms": 2140,
"tokens_per_second": 22.4
}usage uses the Responses names — input_tokens and output_tokens where the chat shape says prompt_tokens and completion_tokens. native_finish_reason carries the same engine-reported cause described under streaming usage chunks. cost_wei, generation_time_ms, tokens_per_second, tenzro_contentProvenance, tenzro_jurisdiction and commitment are the identical bytes the chat surface returns, so a caller that verifies a provenance manifest or a locality receipt there verifies it the same way here.
id is derived from the chat completion id: chatcmpl-abc yields resp_abc and item id msg_abc, so a resp_… in a caller's logs traces back to the chatcmpl-… the gateway recorded for the same generation.
status follows the finish reason. length reports incomplete with incomplete_details.reason: "max_output_tokens"; content_filter reports incomplete with reason: "content_filter"; anything else reports completed. A generation that faulted reports failed and describes the cause in error. An output item is only ever completed or incomplete — a failure is reported on the response, not on the item. store is always false: no response is retained, so reporting true would advertise a previous_response_id follow-up this gateway refuses.
A streamed request emits named SSE events rather than chat.completion.chunk frames. Every event carries type and a monotonic sequence_number starting at 0:
response.created response, status in_progress, empty output
response.in_progress same object
response.output_item.added output_index, item with empty content
response.content_part.added item_id, output_index, content_index, part
response.output_text.delta item_id, output_index, content_index, delta, logprobs
response.output_text.done the accumulated text
response.content_part.done the completed part
response.output_item.done the completed item
response.completed response, the full object
response.incomplete response, when the generation stopped short
response.failed response, when the generation faultedThere is no data: [DONE] sentinel: the terminal event is the terminator. The four opening events are withheld until the first chunk arrives, because that chunk carries the served model id and the completion id the response object is keyed on — emitting response.created earlier would name the model the caller asked for rather than the one that answered. A transport that ends before the generation does still gets a terminal event: response.failed when the connection faulted, response.incomplete when it closed cleanly mid-generation.
Anything the chat body has no home for is refused by name rather than dropped, so a caller is never billed for a completion that quietly ignored the ask. previous_response_id, a non-empty tools, and a tool_choice asking for anything other than none each return a 400 naming what was refused. /v1/completions is not served — the Responses and chat-completions shapes cover the surface.
Audio transcriptions
POST /v1/audio/transcriptions serves speech recognition over any transcriber loaded into this node's audio runtime — Moonshine v2, Distil-Whisper, Whisper-large-v3-turbo, Parakeet-TDT-0.6B-v3, or Canary-1B-Flash. Load one with tenzro_loadAudioModel and list what is loaded with tenzro_listAudioModels. The request is multipart/form-data, matching the OpenAI wire shape, so an unmodified OpenAI SDK client reaches the runtimes without a Tenzro-specific client.
file required, the audio bytes. Body ceiling 128 MiB on this route
model required, a catalog id from tenzro_listAudioCatalog
language source-language hint. Blank is treated as absent
response_format json (default), text, verbose_json, srt, vtt
temperature decoder temperature. Omitted leaves the runtime default
timestamp_granularities segment. Also read from timestamp_granularities[]verbose_json, srt and vtt render per-segment time ranges, so requesting any of them makes the runtime emit segment timestamps whether or not timestamp_granularities asked for them — the body cannot be built without them. A model that returns no time ranges cannot produce a subtitle body, so srt and vtt return a 400 naming the model and pointing at json or text instead.
The response shape follows response_format: json returns {"text": …}; text returns the transcript as a bare text/plain body; verbose_json returns task, language, duration, text and segments[] with id, start, end and text per segment, where duration is the largest segment end time; srt returns a SubRip body as application/x-subrip with HH:MM:SS,mmm timecodes and 1-based cue numbers; vtt returns a WebVTT body as text/vtt with HH:MM:SS.mmm timecodes under a WEBVTT header.
Fields the transcribe configuration has no home for are refused by name rather than dropped, so a caller is never billed for a request whose instructions were silently ignored. timestamp_granularities: "word" is refused because the runtimes emit segment-level ranges only, a non-empty prompt is refused because the decoders take no text conditioning on this route, and any other form field is refused as unknown.
Image generations
POST /v1/images/generations serves text-to-image over the media-generation job queue. The queue is asynchronous: the route posts a job, announces it on tenzro/media-gen, waits for a worker to carry it to a terminal status under a bounded deadline, then fetches the rendered bytes and returns them base64-encoded. Admission, pricing and provenance are the same code path tenzro_mediaGen_postJob uses — this route adds a wire shape, not a second execution path.
model required, a catalog id from tenzro_mediaGen_listCatalog
prompt required
size WIDTHxHEIGHT. Longest side bounded by the catalog entry
n rejected unless 1 — one job renders one artifact
response_format b64_json only. Bytes live in the content-addressed store
requester_did required, the identity the job is posted under
requester_address required, hex address the price is charged against
max_price attoTNZO ceiling, decimal. Defaults to this node's quote
negative_prompt for pipelines that take one
steps denoising steps, defaults to the catalog reference figure
guidance_scale CFG scale, defaults to the catalog reference figure
seed left unset, the worker picks one and reports it
wait_seconds how long to hold the connection open, default and cap 300requester_did and requester_address are required because nothing on an HTTP request carries an authenticated Tenzro principal — the payment gate verifies a credential but does not export the payer. The queue binds every job to the identity that posted it: that identity owns the price ceiling, is the only party that can cancel the job, and is who settlement charges. Substituting the node's own address would bill the operator for a stranger's render and leave the requester unable to cancel it.
job_id is derived by the runtime from the spec contents, so it is not a request field. Whether a job splits its denoising schedule across two experts is read from the catalog, never from the request. quality, style and user are refused by name: the diffusion pipelines take no such control, and steps and guidance_scale are the knobs that exist.
{
"created": 1780560000,
"data": [{ "b64_json": "iVBORw0KGgo…", "revised_prompt": null }],
"tenzro": {
"job_id": "mgen_7c1f…",
"model": "flux-schnell",
"output_mime": "image/png",
"output_hash": "0x9a3e…",
"seed_used": 1024,
"worker_did": "did:tenzro:machine:…",
"generation_time_ms": 4180,
"price_paid": "820000000000000"
}
}revised_prompt is always null — the prompt reaches the worker as sent.
The route re-reads the job status every 250 ms. If the deadline lapses before a worker finishes, the response is a 504 naming the job_id: the render is not abandoned, and the caller polls tenzro_mediaGen_getJob and then tenzro_mediaGen_fetchOutput for the bytes. A 2xx would be the wrong signal there — an OpenAI SDK client reads any 2xx as a rendered image and would fail parsing a body that carries a job id instead of data[].
Image edits
POST /v1/images/edits serves image-to-image over the same job queue. The request is multipart/form-data, matching the OpenAI wire shape for this route, and the reference image is published into the content-addressed store before the job is posted so the worker fetches it by hash rather than receiving it inline.
image required, the reference bytes. Also read from image[] and images[]
prompt required
model required, a catalog id from tenzro_mediaGen_listCatalog
n rejected unless 1 — one job renders one artifact
response_format b64_json only
strength how far the edit may travel from the reference image
requester_did required, the identity the job is posted under
requester_address required, hex address the price is charged against
max_price attoTNZO ceiling, decimal. Defaults to this node's quote
negative_prompt for pipelines that take one
steps denoising steps, defaults to the catalog reference figure
guidance_scale CFG scale, defaults to the catalog reference figure
seed left unset, the worker picks one and reports it
wait_seconds how long to hold the connection open, default and cap 300The pipeline is fixed by the route, never by the body: this path is image-to-image whatever a request field claims. Letting a field override it would let a caller reach a pipeline the route was not priced for.
Vendor controls the pipelines have no home for are refused by name. mask is refused because inpainting is a separate pipeline call, and serving a masked request as a whole-frame edit would answer a different question than the one asked. background is refused because the pipelines render opaque frames and have no alpha channel to make transparent. input_fidelity is refused in favour of strength, which is the knob that exists. output_format and output_compression are refused because the container is the worker's and is reported back as output_mime. stream and partial_images are refused because the queue reports a terminal receipt, not partial denoising steps. quality and style are refused as on the generations route, and user is refused because the job is bound to requester_did, which is an authenticated identity rather than a free-form label.
{
"created": 1780560000,
"data": [{ "b64_json": "iVBORw0KGgo…", "revised_prompt": null }],
"tenzro": {
"job_id": "mgen_4b82…",
"model": "flux-kontext",
"input_image_hash": "0x51c7…",
"output_mime": "image/png",
"output_hash": "0xd0f4…",
"seed_used": 7,
"worker_did": "did:tenzro:machine:…",
"generation_time_ms": 6240,
"price_paid": "1140000000000000"
}
}input_image_hash is the store hash of the reference the worker actually read, so a receipt names both sides of the edit.
Video renders
POST /v1/videos serves video rendering. It is multipart/form-data and, unlike the two image routes, it is a job resource: the POST returns immediately with a queued video, the caller polls GET /v1/videos/{id} until it reports completed, then reads the bytes from GET /v1/videos/{id}/content. A render that takes minutes has no business holding a connection open for them, and this is the shape the vendor publishes for the route.
prompt required
model required, a catalog id from tenzro_mediaGen_listCatalog
input_reference optional reference image. Present selects image-to-video,
absent selects text-to-video
seconds clip length. Multiplied by fps to get the frame count
size WIDTHxHEIGHT. Longest side bounded by the catalog entry
fps frame rate. Defaults to the catalog entry's figure
requester_did required, the identity the job is posted under
requester_address required, hex address the price is charged against
max_price attoTNZO ceiling, decimal. Defaults to this node's quote
negative_prompt for pipelines that take one
steps denoising steps, defaults to the catalog reference figure
guidance_scale CFG scale, defaults to the catalog reference figure
seed left unset, the worker picks one and reports itPresence of input_reference is what selects the pipeline, so a text-to-video model that receives a reference image is refused rather than served a request it cannot honour. seconds must be a finite number above zero; the frame count is seconds × fps rounded, floored at one frame. Omitting seconds renders the catalog entry's default frame count. user is refused by name for the same reason as on the image routes.
{
"id": "mgen_e93a…",
"object": "video",
"model": "wan-t2v",
"status": "queued",
"progress": 0,
"created_at": 1780560000,
"prompt": "a paper boat crossing a still pond",
"size": "832x480",
"seconds": "5",
"tenzro": {
"job_id": "mgen_e93a…",
"kind": "text2video",
"split": false,
"last_update": 1780560000,
"max_price": "9200000000000000"
}
}status maps the queue's own states onto the four the vendor shape defines: a pending or claimed job reports queued, a running job reports in_progress, and a failed or cancelled job reports failed with an error object carrying the worker's message. progress is a checkpoint count rather than an interpolation — the queue knows which stage a job reached, not what fraction of its denoising schedule is done — so the figure steps rather than climbing. expires_at is omitted rather than guessed: the rendered bytes live in the content-addressed media store under their own hash and are not swept on a clock. A completed job adds completed_at and the receipt fields under tenzro — output_mime, output_hash, output_bytes, seed_used, worker_did, generation_time_ms and price_paid.
GET /v1/videos/{id}/content returns the clip with the worker's Content-Type and a Content-Disposition filename derived from the job id. Asking for it before the job completes is a 409 naming the current status and the endpoint to poll — an SDK client writes any 2xx body straight to a file, and a zero-length clip is harder to diagnose than a status code naming what to poll. A variant query other than video is refused: the pipelines render the clip itself, with no thumbnail or spritesheet derivative.
Forecasts
POST /v1/tenzro/forecasts serves timeseries forecasting over any model loaded into this node's timeseries runtime. Load one with tenzro_loadForecastModel and list what is loaded with tenzro_listForecastModels. It sits under the Tenzro namespace because no vendor covers timeseries forecasting on an OpenAI-shaped surface: there is no /v1/forecasts to be compatible with, and inventing one under the vendor's namespace would collide the day they publish theirs.
model required, a loaded forecast model id
history required, a non-empty array of observations in time order
horizon required, how many steps to forecast. At least 1
quantiles quantile levels to return. Omitted returns the runtime default
frequency_seconds sampling interval of the history, for models that read one{
"object": "forecast",
"model": "timesfm-2.5",
"point": [104.2, 106.8, 108.1],
"quantiles": [[98.4, 104.2, 110.9], [99.1, 106.8, 114.2], [99.6, 108.1, 116.8]],
"quantile_levels": [0.1, 0.5, 0.9],
"generation_time_ms": 84
}point is the median path. quantiles is one row per forecast step, each row ordered to match quantile_levels.
Detections
POST /v1/tenzro/detections serves object detection over any model loaded into this node's detection runtime — the RF-DETR and D-FINE families. Load one with tenzro_loadDetectionModel and list what is loaded with tenzro_listDetectionModels.
model required, a loaded detection model id
image_base64 required, the image bytes base64-encoded
score_threshold confidence floor, default 0.25{
"object": "detection",
"model": "rf-detr-base",
"detections": [
{ "bbox": [412.0, 118.5, 688.2, 540.9], "label_id": 3, "score": 0.91 }
],
"generation_time_ms": 61
}bbox is [x1, y1, x2, y2] in pixels against the image as sent. Both families are NMS-free, so the returned boxes are the model's own output with no suppression pass applied.
Segmentations
POST /v1/tenzro/segmentations serves promptable segmentation. A geometric prompt reaches the SAM 1 and SAM 2 runtime; a text prompt reaches the open-vocabulary SAM 3 runtime. Load them with tenzro_loadSegmentationModel and tenzro_loadTextSegmentationModel, and list what is loaded with tenzro_listSegmentationModels and tenzro_listTextSegmentationModels.
model required, a loaded segmentation model id
image_base64 required, the image bytes base64-encoded
prompts geometric prompts — points and boxes
text_prompt a noun phrase, for the open-vocabulary runtime
box_prompt narrows a text_prompt to a region
score_threshold confidence floor. 0.5 on the text pathprompts and text_prompt name different runtimes holding different models, so exactly one of them is required and sending both is refused rather than resolved by precedence. box_prompt narrows a text_prompt; a geometric box travels as an entry in prompts, and sending box_prompt without a text prompt is refused as misplaced.
# text prompt
{ "object": "segmentation", "model": "sam3", "generation_time_ms": 240,
"segmentations": [
{ "bbox": [88.0, 140.0, 502.0, 690.0], "score": 0.88,
"width": 1024, "height": 1024, "mask_base64": "iVBORw0KGgo…" }
] }
# geometric prompts
{ "object": "segmentation", "model": "sam2-large", "generation_time_ms": 190,
"masks": [
{ "width": 1024, "height": 1024, "score": 0.96, "mask_base64": "iVBORw0KGgo…" }
] }Masks travel base64-encoded. A 1024² mask as a JSON array of integers is roughly 3 MB of text for one artifact, and no vendor standard governs the noun, so nothing is lost by encoding it compactly.
Video embeddings
POST /v1/tenzro/video/embeddings embeds a clip into a single vector. It is separate from /v1/embeddings because a clip arrives as one artifact and returns one vector plus a frame count, where the vendor's embeddings shape returns a data[] list with no room to report how much of the clip was consumed.
model required, a loaded video model id
video_base64 required, the clip bytes base64-encoded
normalize L2-normalize the returned vector, default false
frame_stride sample every Nth frame instead of the runtime default{
"object": "video_embedding",
"model": "clip-vit-l14-frames",
"embedding": [0.014, -0.221, …],
"dim": 768,
"frames_consumed": 16,
"generation_time_ms": 1420
}The route is served but the catalog's three V-JEPA 2 entries cannot currently be loaded: the upstream facebook/vjepa2-* repositories carry safetensors only, with no ONNX export, so tenzro_loadVideoModel refuses them. They stay in the catalog so licence-tier gating, discovery and enumeration report the options correctly. The working path is registering an already-loaded image encoder as a frame-wise encoder, which samples frames, embeds each one and mean-pools. Calling the route with nothing loaded returns a 400 naming the RPC that loads a model.
Client setup
Point any OpenAI SDK at the Tenzro node's base URL. No client changes.
# Python OpenAI SDK
from openai import OpenAI
client = OpenAI(
base_url="https://rpc.tenzro.xyz/v1",
api_key="tnz_...", # or any non-empty string for the ungated route
)
resp = client.chat.completions.create(
model="qwen3-8b",
messages=[{"role": "user", "content": "hello"}],
stream=True,
)// TypeScript OpenAI SDK
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://rpc.tenzro.xyz/v1",
apiKey: "tnz_...",
});
const resp = await client.chat.completions.create({
model: "qwen3-8b",
messages: [{ role: "user", content: "hello" }],
stream: true,
});Model listing entry
Each entry in GET /v1/models carries the OpenAI core fields plus the Tenzro serving contract:
{
"id": "svc-8f3a…",
"object": "model",
"created": 1780560000,
"owned_by": "0x4b2c…",
"model_id": "qwen3-8b",
"model_name": "Qwen 3 8B",
"location": "local",
"status": "online",
"context_length": 32768,
"max_output_tokens": 2048,
"pricing": {
"input_wei_per_token": "1000000000000",
"output_wei_per_token": "3000000000000",
"minimum_wei": "0",
"pricing_model": "PerToken",
"prompt": "0.00000005",
"completion": "0.00000015",
"request": "0",
"currency": "USD",
"tnzo_usd": "0.05"
},
"features": {
"streaming": true,
"usage_in_stream": true,
"mtp": true,
"provenance_signing": true,
"jurisdiction_signing": true,
"supported_parameters": ["temperature", "top_p", "top_k", "min_p", "max_tokens", "frequency_penalty", "presence_penalty", "repetition_penalty", "stop", "seed", "stream", "stream_options", "user", "draft_n", "verifiable", "jurisdiction", "jurisdiction_receipt", "models", "provider"]
},
"datacenter_location": "us-central1",
"datacenters": [{ "country_code": "US" }],
"api_endpoint": "https://…",
"mcp_endpoint": "https://…"
}id — for local service instances, the instance ID; for gossip-discovered network models, the model ID. Either form is accepted as model in a chat request. context_length / max_output_tokens come from the model registry, falling back to the built-in catalog; null when neither source knows. The wei figures in pricing are decimal strings (values exceed JSON's safe integer range) and are the amounts that settle under the default PerToken scheme.
pricing_model decides what a settled call is charged on: PerToken meters every consumed dimension at its own rate, PerRequest is a flat charge per call regardless of what it consumed, PerComputeTime charges the measured latency by the millisecond, and Dynamic meters as PerToken then scales toward the average that model's finished calls have settled at on the serving node, bounded to between half and twice the metered figure so one outlier in a thin market cannot multiply a bill without limit. minimum_wei is the floor under all four. A node settling a call it did not serve holds the provider's signed pricing configuration but not its settlement history, so a Dynamic quote settles at metered cost when the settling node has no anchor rather than guessing a scale.
pricing.prompt / completion / request restate those prices in USD per token for marketplaces that list in fiat, derived from the operator's declared TNZO listing rate and echoed as pricing.tnzo_usd. An operator who declares no rate omits all five keys — a listing without a prompt key is unpriced in USD, not free.
datacenter_location is the provider's declared geography from its gossip announcement — null means undeclared, not global. datacenters carries the ISO 3166-1 alpha-2 country code from the operator's jurisdiction claim, attestation-bound on TEE hardware. The code is never derived from the region string: eu-west and ap-southeast span many countries, so projecting them would misdeclare where the hardware sits.
Data policy
The list response carries a top-level data_policy object — the gateway's machine-readable data-handling declaration:
{
"object": "list",
"data": [ … ],
"data_policy": {
"prompt_retention": "none",
"completion_retention": "none",
"stream_resume_buffer_secs": 300,
"trains_on_data": false,
"usage_accounting": "metered_units_cost_latency_only"
}
}Prompt and completion bodies are never written to disk. SSE chunks live in an in-memory resume buffer for stream_resume_buffer_secs, then expire. The usage tracker records the metered units, cost, and latency only — never the content that produced them.
Streaming usage chunks
Every stream ends with a finish_reason chunk that also carries usage and the billing extensions, so a caller learns what it was billed without opting into anything. stream_options: {"include_usage": true} appends the OpenAI empty-choices usage chunk after it, repeating the same numbers in the shape OpenAI SDKs look for:
{"id": "chatcmpl-…", "object": "chat.completion.chunk", "created": 1780560000, "model": "qwen3-8b",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop", "native_finish_reason": "stop_sequence"}],
"usage": {"prompt_tokens": 12, "completion_tokens": 48, "total_tokens": 60},
"cost_wei": "156000000000000", "generation_time_ms": 2140, "tokens_per_second": 22.4}
{"id": "chatcmpl-…", "object": "chat.completion.chunk", "created": 1780560000, "model": "qwen3-8b",
"choices": [],
"usage": {"prompt_tokens": 12, "completion_tokens": 48, "total_tokens": 60},
"cost_wei": "156000000000000", "generation_time_ms": 2140, "tokens_per_second": 22.4}
data: [DONE]Two finish reasons ride together. finish_reason is the OpenAI vocabulary — stop, length, tool_calls, or content_filter — so SDK parsers that branch on it keep working. native_finish_reason beside it is the exact cause reported by whatever produced the tokens. On a locally served model that is eos (the model emitted an end-of-sequence token), stop_sequence (one of the stop strings matched — and since the matched suffix is trimmed out of the text, this is the only signal that it fired), or length (the max_tokens budget was exhausted). On a network model the serving peer's own spelling passes through verbatim.
cost_wei, generation_time_ms, and tokens_per_second are extension fields; OpenAI SDK parsers ignore them. Non-streaming responses carry the same usage object plus the same extension fields at the top level, and their single choice carries both finish reasons. When the request targets a network model, the node forwards the upstream SSE stream as it arrives — usage chunks emitted by the serving node pass through to the client. The only rewrite is on frames carrying a finish_reason: the serving peer's spelling moves to native_finish_reason and finish_reason is normalized, so a client sees one vocabulary regardless of which engine answered.
Generation stats lookup
The counts and cost of a generation ride on the response that carried it, which a streamed caller frequently never reads: the terminal chunk arrives after the text the caller was waiting for, and closing the connection there is ordinary client behaviour. GET /v1/generation?id=<completion_id> resolves the completion id the caller already holds back to what was recorded when the generation finished.
{
"id": "chatcmpl-9f3c8a1e-…",
"model": "qwen3-8b",
"provider": "0x8f2b…",
"input_tokens": 12,
"output_tokens": 48,
"total_tokens": 60,
"bytes_in": 214,
"bytes_out": 1108,
"cost_wei": "156000000000000",
"latency_ms": 2140,
"tokens_per_second": 22.4,
"created": 1780560000
}The id is the chatcmpl-… returned by any of the chat routes, or the request_id of an inference dispatched over JSON-RPC. provider is the payee address of the node that served the tokens — the local payee for a locally served generation, the serving peer for a routed one. bytes_in / bytes_out are the byte counts of what that path moved: prompt and completion text locally, HTTP request and response bodies when routed, so the two are not directly comparable. tokens_per_second is omitted when the recorded latency is zero.
input_tokens is the whole prompt as an OpenAI-compatible caller counts it, so a cache read sits inside it rather than beside it. total_tokens sums every token dimension — prompt, completion, both cache directions, and image-derived — so it exceeds input_tokens + output_tokens whenever a call wrote to cache or carried an image.
Those are the fields every generation has. A model is billed on more than tokens, and the dimensions carrying that work appear only when the call consumed them, so a plain chat completion is not padded with zeroed media fields: cached_read_tokens and cached_write_tokens (tokens, priced apart from the rest of the prompt), reasoning_loops (a recurrent-depth model billed on loop depth rather than tokens), image_tokens (tokenized per that model family's descriptor rather than by one shared formula), audio_seconds and video_seconds (whole seconds, rounded up from the recorded milliseconds), frames, and pixel_steps (width × height × steps × frames of denoising work). cost_wei and pixel_steps are decimal strings and everything else is a number — both run past 253, where JSON.parse would silently round them.
A generation is recorded when it finishes, so 404 means the id is unknown to this node, the generation is still running, or it failed before completing. The route is not payment-gated — reading back counts the caller was already told is not itself billable. The same read is available over JSON-RPC as tenzro_getGeneration, which answers -32004 where the HTTP route answers 404. To read the history behind one id rather than the id itself, tenzro_listInferenceUsage takes optional model_id and provider filters: both give the matching records, either alone gives that model's or provider's rollup, neither gives the global rollup plus per-model and per-provider breakdowns. That surface returns the stored shape rather than this reshaped one — every dimension present, durations in milliseconds, and the provider address as 32 bytes.
Stream resume
Streams support reconnection via the SSE Last-Event-ID header. Every event's id is <completion_id>:<seq>; a reconnecting client sends Last-Event-ID: <completion_id>:<seq> and the node replays buffered chunks with a higher sequence number, synthesizing [DONE] if the stream already finished. The buffer is in-memory and expires per data_policy.stream_resume_buffer_secs.
Streaming failover
The Last-Event-ID resume above covers a dropped client connection. When the drop is on the provider leg — the gateway is proxying a network model and the serving provider dies mid-generation — the gateway continues the stream on a different provider without a visible restart. It captures the sampling state as it forwards tokens (messages, seed, temperature, top_p, max_tokens, and the assistant text emitted so far), then re-sends to another provider with the emitted text as a trailing assistant prefix and continue_final_message: true. The seed travels with the request so the new provider re-prefills the identical prefix and resumes sampling from the same distribution. No KV-cache bytes cross the wire. Bounded single retry: a second drop ends the stream. Pin seed for byte-identical continuation.
Sampling parameters
The standard OpenAI sampling fields are honoured on the local serving path and forwarded verbatim on the network path:
temperature 0.0..2.0, default 0.7
top_p default 0.9
top_k omitted leaves the candidate set untruncated by rank
min_p probability floor relative to the most likely token
frequency_penalty per-occurrence logit penalty, default 0.0
presence_penalty flat penalty for any token already present, default 0.0
repetition_penalty penalty over the recent window, default 1.1
stop string or array of strings
seed default 42
max_tokens default 512
stream_options {"include_usage": true} appends the usage-only chunk
user opaque end-user tag, forwarded to the serving providerOmitting top_k or min_p leaves that truncation stage out entirely rather than inserting a neutral-valued one. stop matches are trimmed from the returned text, so the delimiter never reaches the caller. n is rejected with 400 unsupported_n unless it is 1 — the network bills per completion and fanning one request into several would hide the multiplier. logprobs, top_logprobs, and logit_bias are not accepted; use verifiable for a logit commitment.
Message content parts
A message content is either a bare string or an array of typed parts. Both shapes are accepted, and a proxied request reaches the serving provider in the shape the client sent — a provider that accepts only one form still receives a valid request.
text {"type": "text", "text": "…"}
image_url {"type": "image_url", "image_url": {"url": "data:… inlined bytes, or https://… for a peer that fetches", "detail": "auto|low|high"}}
input_audio {"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav|mp3"}}
file {"type": "file", "file": {"file_id": "…"}} or {"file_data": "<base64>", "filename": "…"}{
"model": "qwen3-8b",
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "What is in this image?" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgo...", "detail": "high" } }
]
}
]
}An image_url part renders on a locally-served model that loaded a multimodal projector — read accepts_media on tenzro_listModelEndpoints to see which do. The bytes must be inlined as a data: URI: a serving node does not fetch a caller-named remote resource, so an https:// URL is refused with 400 invalid_image_part and the message says to inline instead. Undecodable base64 is refused the same way.
input_audio and file parts, and any image_url sent to a model serving text only, are refused with 400 unsupported_content_part naming the part type, rather than served a completion that ignored the part. On the network path the parts array is forwarded intact, including remote URLs, so a peer serving a model that renders them can answer.
Attachments bind to the prompt in part order: the nth image across the whole request is the nth attachment the model sees. A request carrying one generates in full before the response opens, so stream: true delivers the reply as a single delta followed by the usual finish and usage chunks.
Multiple text parts are newline-joined when flattened for a text-only runtime. Jurisdiction receipts hash the parts array as its JSON, so image URLs and inlined bytes are bound by the receipt alongside the text, and streaming failover carries the parts through to the continuation provider.
Tenzro extensions
The wire shape stays OpenAI-compatible; Tenzro-specific parameters ride on the request body as extension fields. OpenAI SDKs pass unknown fields through unchanged.
models fallback model IDs tried in order when model has no reachable offer
provider {"only": [...], "ignore": [...]} restrict which providers may serve
draft_n 1..6, opt in to Multi-Token-Prediction speculative decoding
verifiable true, request a TOPLOC top-k logit commitment
jurisdiction "DE,EU", hard-filter to providers matching the locality pin
jurisdiction_receipt "required", fail unless a signed locality receipt comes backdraft_n — the target model must be catalog-marked as MTP-capable (see features.mtp in the model listing). verifiable — non-streaming, local single-token path only; externally-fronted engines (vLLM, SGLang) return no commitment. jurisdiction — refusals return HTTP 412 with error codes jurisdiction_not_satisfied or jurisdiction_receipt_unavailable. See Inference for the routing behavior behind each flag.
Model fallback and provider pinning
models lists fallback model IDs in preference order. The gateway tries model first, then each models entry, and serves the first one an admitted provider offers. Blank entries and duplicates of an earlier candidate are dropped. When nothing in the list resolves, the 404 names every candidate that was tried.
{
"model": "qwen3-8b",
"models": ["qwen3-4b", "gemma4-e4b"],
"provider": { "ignore": ["0x9c1d…"] },
"messages": [{ "role": "user", "content": "Hello" }]
}The response's model field is the candidate actually served, so a caller always knows which one answered.
provider narrows the pool: a non-empty only restricts routing to those providers, and ignore removes providers, applied after only. An entry matches a provider's announced name or its address in either the base58 or hex spelling, case-insensitively, with an optional 0x prefix. A pin that admits no provider for any candidate model yields 404 model_not_found — the gateway does not silently serve an excluded provider.
The pin also governs streaming failover: a continuation provider must satisfy it. The fallback models list does not, because a continuation replays the already-emitted text as an assistant turn and a different model tokenizes that prefix differently. A stream stays on the model it started with.
Payment gate
/v1/chat/completions and /v1/responses are open; /api/paid/chat/completions is the same handler behind an HTTP 402 payment gate. When the gate is enabled, unauthenticated requests to the paid path get a 402 Payment Required challenge carrying the payment protocol descriptor (MPP, x402, or native TNZO channel). Present a valid credential on retry and the request proceeds. The ungated route accepts any non-empty bearer token — API-key gating on the ungated route is per-operator via X-Tenzro-Api-Key.
Errors
Errors follow the OpenAI envelope: {"error": {"message": …, "type": …, "code": …}}.
400 unsupported_n n present and not 1. On /v1/images/generations,
one job renders one artifact
400 model_not_loaded resolves here but is not serving. On
/v1/audio/transcriptions and /v1/embeddings,
no transcriber or encoder is loaded under that
id; the message names the RPC that loads one
400 unsupported_content_part an input_audio or file part, or an image_url
for a model that loaded no projector. On
/v1/responses, also an input_image that
carries only a file_id
400 invalid_image_part an image_url that is not a data: URI, or
whose base64 payload does not decode
401 — missing or invalid API key on a key-gated route
402 — payment required on the paid route
404 model_not_found no local instance and no network model matches.
On /v1/images/generations, no media-generation
catalog entry carries that id
412 jurisdiction_not_satisfied no matching locality claim
412 jurisdiction_receipt_unavailable required, but no local claim or signer
429 — rate limited
500 runtime_unavailable no model runtime initialized on this node
500 inference_error local generation failed
502 provider_unreachable provider unreachable over iroh and HTTP
502 provider_error provider returned a non-success status
502 jurisdiction_receipt_unavailable required, but provider returned no receiptThe /v1/responses translation layer refuses anything the chat body has no home for:
400 missing_input input absent or an empty array
400 invalid_input_item an input entry was not an object, or its
content was neither a string nor an array
400 unsupported_input_item an input entry declared a type other than
message. The message names the type
400 invalid_content_part a content part was not an object, or an
input_file carried neither file_id nor
file_data
400 unsupported_previous_response_id no response is retained, so prior turns
must be replayed in input
400 unsupported_tools a non-empty tools list. An absent or empty
list is accepted
400 unsupported_tool_choice a tool_choice asking for anything but none
502 completion_unreadable the completion body could not be read
before rewriting
502 completion_malformed the completion body was not the JSON the
rewrite expects/v1/audio/transcriptions refuses a malformed upload or a field the transcribe configuration has no home for:
400 malformed_multipart the body was not readable as multipart
400 unreadable_form_field a field's bytes could not be read
400 unknown_form_field a field outside the accepted set. The
message names it
400 missing_file no file part
400 missing_model no model field
400 unsupported_response_format outside json, text, verbose_json, srt, vtt
400 invalid_temperature temperature was not a float
400 unsupported_timestamp_granularity word. The runtimes emit segment ranges only
400 unsupported_prompt a non-empty prompt. These decoders take no
text conditioning on this route
400 timestamps_unavailable srt or vtt asked of a model that returns no
time ranges. The message names json and text
500 transcription_error the runtime failed on this audio/v1/images/generations refuses a spec the queue would not admit, and reports a lapsed deadline rather than an empty success:
400 unsupported_response_format anything other than b64_json
400 unsupported_media_kind the catalog entry does not render images
400 unsupported_image_control quality, style or user. steps and
guidance_scale are the knobs that exist
400 invalid_size size was not WIDTHxHEIGHT
400 resolution_exceeded the longest side exceeds the catalog bound
400 invalid_requester_address requester_address was not a hex address
400 invalid_max_price max_price was not a decimal attoTNZO amount
400 job_not_admitted admission refused the spec. The message
carries the reason
500 job_vanished the job left the queue without a receipt
500 receipt_missing the job completed with no receipt recorded
502 job_failed a worker reported failure. The message
carries the worker's reason
502 output_unreachable the receipt is present but the bytes could
not be fetched from the media store
504 render_timeout wait_seconds lapsed with the job still
running. The message names the job_id to
poll; the render continues/v1/images/edits and /v1/videos are multipart, so they share malformed_multipart, unreadable_form_field and unknown_form_field with the transcriptions route, and add:
400 invalid_number a numeric field was not a number. The message
names it and echoes what arrived
400 unsupported_media_control a vendor control the pipelines have no home
for. The message names the field and why
400 missing_image /v1/images/edits with no reference image
400 missing_prompt no prompt field
400 missing_model no model field
400 missing_requester_did no requester_did field
400 missing_requester_address no requester_address field
400 invalid_fps fps resolved to zero
400 invalid_seconds seconds was not a finite number above zero
404 video_not_found no video job under that id on this node
409 video_not_ready content asked for before the job completed.
The message names the status and the
endpoint to poll
400 unsupported_variant a content variant other than video
500 catalog_entry_incomplete the catalog entry declares neither an fps nor
a default frame countThe four Tenzro-namespaced routes share one shape. A model that is not loaded is a 400 whose message names the RPC that loads one and the RPC that lists what is loaded, so a caller can recover without reading these tables; any other runtime failure is a 500 named for the modality.
400 model_not_loaded nothing is loaded under that id. The message
names the load and list RPCs
400 invalid_base64 an inline image or clip was not readable
base64. The message names the field
400 missing_history /v1/tenzro/forecasts with an empty history
400 invalid_horizon horizon below 1
400 ambiguous_segmentation_prompt both prompts and text_prompt. They name
different runtimes holding different models
400 missing_segmentation_prompt neither prompts nor text_prompt
400 misplaced_box_prompt box_prompt without a text_prompt. A geometric
box travels as an entry in prompts
500 forecast_error the timeseries runtime failed on this input
500 detection_error the detection runtime failed on this image
500 segmentation_error the segmentation runtime failed on this image
500 video_embedding_error the video runtime failed on this clip