Media
POST /v1/media/generate and POST /v1/media/vision give a host app a model
without an agent. No plan, no tools, no transcript — you send a prompt or some
images and get an answer back on the same connection.
Both spend the connected user’s credits. Image generation, speech and vision are synchronous; that property drives most of this page. 3D models are the one asynchronous type — a job you poll — and have their own section.
Generate an image
Section titled “Generate an image”Scope: media.generate.
curl -X POST https://api.tanzanite.ai/v1/media/generate \ -H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-job-7f3c1e" \ -d '{ "mediaType": "image", "prompt": "A moss-covered stone archway at dusk, painterly, muted palette", "size": "1024x1024", "projectId": "proj_7bd…" }'{ "mediaType": "image", "url": "https://storage.example/signed/…", "expiresAt": "2026-09-10T10:12:04.000Z", "storagePath": "media/…/7f3c1e.png", "mimeType": "image/png", "width": 1024, "height": 1024, "model": "…", "costUSD": 0.042, "idempotent": false}Request fields
Section titled “Request fields”| Field | Notes |
|---|---|
mediaType |
"image". See why this is a field. |
prompt |
What to generate. Required. |
model |
Provider-namespaced model id. Omit for the platform default. |
size |
e.g. "1024x1024". |
aspectRatio |
e.g. "16:9". |
resolution |
e.g. "1080p". |
quality |
Provider-dependent. |
seed |
Reproducibility, where the provider honours it. |
sourceImage |
Edit or vary an existing image instead of generating from the prompt alone. A URL the platform can fetch, or inline base64 plus mimeType. |
projectId |
Bill and file the result under one of your projects. |
size, aspectRatio and resolution overlap — pick the one your model
understands rather than sending all three.
Set projectId. Without it the result is not attached to anything the user can
find in the Tanzanite interface, and the spend is harder for them to place.
model in the response is the model that actually served the request, which
is not always the one you asked for. Log it if you care.
Generate speech
Section titled “Generate speech”Scope: media.generate. Text to speech — a trailer voiceover, a narrator line —
on the same endpoint, synchronous, under the same ceiling, with the same
Idempotency-Key replay.
curl -X POST https://api.tanzanite.ai/v1/media/generate \ -H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-vo-91c2" \ -d '{ "mediaType": "speech", "input": "In a world where every block falls, one hero builds.", "voice": "am_michael", "format": "mp3", "projectId": "proj_7bd…" }'{ "mediaType": "speech", "url": "https://storage.example/signed/…", "expiresAt": "2026-10-25T10:12:04.000Z", "storagePath": "media/…/91c2.mp3", "mimeType": "audio/mpeg", "durationMs": 4180, "model": "hexgrad/kokoro-82m", "voice": "am_michael", "chars": 52, "costUSD": 0.000036, "costSource": "provider-actual", "generationId": "gen-1761400000-…", "provider": "DeepInfra", "idempotent": false}| Field | Notes |
|---|---|
mediaType |
"speech". |
input |
The text to speak. Required, at most 4000 characters — split a longer script into several calls. |
model |
An enabled speech model, e.g. hexgrad/kokoro-82m (cheap, fast). Omit for the platform default: the cheapest enabled model for a typical ~600-character line. Gemini TTS (google/gemini-*-tts, expressive, takes instructions) is priced per token and is off until its pricing is verified on a live call — asking for it is a 400 saying “pricing for this model is not verified yet”. |
voice |
One of the model’s voices. Kokoro: am_michael, am_onyx, am_fenrir, am_puck, af_heart, af_bella, af_nova, bm_daniel, bf_emma, … Gemini: Puck, Fenrir, Charon, Kore, Zephyr, Orus, … An unknown voice is a 400. |
format |
"mp3" (default) or "wav" — a preference. The format follows the model: Gemini TTS and HeyGen answer only WAV, MiniMax only MP3. mimeType says what you got. |
speed |
Playback multiplier, where the model honours it. |
instructions |
Delivery direction (“deep, dramatic trailer narrator”), ≤ 1000 characters. Gemini, OpenAI and Fish Audio TTS honour it; other models ignore it. |
projectId, tag, label |
As for images. |
durationMs is measured from the file (the WAV header, or by walking the MP3’s
frames). If the file cannot be read, it is estimated from the text and the result
carries "durationEstimated": true.
What it costs. You pay what the call really cost: after the audio comes
back, Tanzanite reads OpenRouter’s own record of the generation (its
X-Generation-Id) and bills that, plus the platform markup —
"costSource": "provider-actual", with the generationId and the upstream
provider that served it. The record can lag a moment; if it has not answered
within about 3 seconds, the call is billed from the model’s catalog price
instead ("costSource": "catalog"): most speech models are priced per
character of input (Kokoro ≈ $0.62 per million characters before the
markup), a few per second of the measured audio (Seed Audio). costUSD is
always exactly what was charged.
The hold is priced from the model’s catalog rate and the text before the call
(over a slow read, for per-second models); an empty wallet is
402 insufficient_credits. If the real cost comes in above the hold, the full
real cost is still charged — the call already happened — and your next call is
gated on what is left. Ask first with
GET /v1/estimates?kind=speech&chars=N
(a catalog estimate, for the same default model when you name none).
Vision
Section titled “Vision”Scope: media.vision.
curl -X POST https://api.tanzanite.ai/v1/media/vision \ -H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "images": [{ "url": "https://storage.example/signed/…" }], "question": "Does the archway read as passable, or as a dead end?", "measuredContext": "1024x1024 PNG. Generated from the prompt: a moss-covered stone archway at dusk.", "projectId": "proj_7bd…" }'{ "text": "It reads as passable…", "model": "…", "costUSD": 0.003, "truncated": false, "imageCount": 1}images takes 1 to 4 entries, each either a URL the platform can fetch or
inline base64 plus mimeType. More than four is refused rather than silently
truncated.
Put everything you already know into measuredContext: dimensions, the
prompt that produced the image, what it is meant to depict, the constraint you
are checking against. A vision model asked to infer a fact it cannot see from the
pixels does not say so — it answers confidently and wrongly. measuredContext is
the cheapest accuracy improvement available on this endpoint.
truncated: true means the answer hit the output budget and was cut. Ask a
narrower question rather than retrying the same one.
To show the price first,
GET /v1/estimates?kind=vision&promptTokens=N&completionTokens=M
prices the call on the same model this endpoint would pick (pass the same
model, or none for the platform’s).
Generate a 3D model
Section titled “Generate a 3D model”Scope: media.generate — the same scope as images, so connections that can
already generate images need no new consent. Same endpoint, mediaType: "model".
A model is written as code: an LLM writes a three.js builder script, the platform runs it in a sandbox, exports GLB, renders previews, has a vision model review them, and fixes what the review finds. It suits props, buildings, vehicles and stylized creatures; it will not match a sculpted text-to-3D mesh for organic detail. It takes one to five minutes, so it never holds the connection:
curl -X POST https://api.tanzanite.ai/v1/media/generate \ -H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-model-91ac" \ -d '{ "mediaType": "model", "prompt": "a weathered wooden lighthouse", "quality": "standard", "kind": "building", "targetHeight": 12, "style": "stylized low-poly", "projectId": "proj_7bd…" }'The answer is 202 with a job:
{ "jobId": "mj_3f9a…", "mediaType": "model", "status": "queued", "quality": "standard", "progress": { "stage": "queued", "message": "queued" }, "pollUrl": "/v1/media/jobs/mj_3f9a…", "pollAfterMs": 3000, "estimatedSeconds": 150, "createdAt": "2026-10-21T09:00:00.000Z", "updatedAt": "2026-10-21T09:00:00.000Z", "costUSD": 0, "idempotent": false}Poll GET /v1/media/jobs/:jobId (scope media.generate) every pollAfterMs
until status is succeeded or failed. Only the connection that started a job
can read it; anything else is 404 job_not_found, as is a job older than 24
hours. progress.stage moves through writing, reviewing, fixing,
packaging.
Request fields
Section titled “Request fields”| Field | Notes |
|---|---|
mediaType |
"model". |
prompt |
What to build. Required, up to 2000 characters. |
quality |
draft, standard (default) or hero — see below. |
targetHeight |
Metres, 0.05–200. Set it. Without it every model arrives at whatever size the script happened to choose. Every returned level has exactly this height. |
style |
e.g. "low-poly", "stylized", "voxel". Keeps one game’s models a set. Up to 120 characters. |
kind |
prop (default), building, vehicle, creature. Selects the guidance and the triangle budget the model works to. |
referenceImageUrl |
An https image to build toward — e.g. one from image generation. The platform fetches it. |
lods |
Which detail levels to return, from lod0, lod1, lod2. Default: draft → lod0 + lod2; standard and hero → all three. |
rig |
none (default, a static mesh), humanoid, quadruped, bird, serpent or custom: build on a skeleton and return a skinned GLB with animation clips — see below. |
baseSource |
The source.code of an earlier result. prompt is then an edit instruction (“make it taller, red roof”) applied to that script, so the model stays recognisably the same. |
projectId, tag, label |
As on the other spend endpoints. |
Quality
Section titled “Quality”quality |
What happens | Measured cost / time | For |
|---|---|---|---|
draft |
One writing pass, no review. | ~$0.03 · 1 min | Prototyping, background props. |
standard |
Written, reviewed from rendered images, up to two fix rounds. | ~$0.05 · 2 min | Most props and buildings. |
hero |
A stronger writer, up to three fix rounds. | ~$0.4–0.6 · 4–5 min | Bosses, key characters, set pieces. Not a crate. |
Costs are what the platform measured on its own prompts (OpenRouter prices,
2026-10-21) and are what the user is charged, markup included — treat them as
orders of magnitude, not quotes. Each level also has a spend cap (the job
stops calling models when it is reached and returns the best model it has), and
the wallet hold placed while a job runs is sized to that cap, so a hero request
needs more headroom in the user’s balance than its typical cost.
The finished job
Section titled “The finished job”{ "jobId": "mj_3f9a…", "mediaType": "model", "status": "succeeded", "costUSD": 0.061, "result": { "mediaType": "model", "lods": [ { "lod": "lod0", "url": "https://storage.example/signed/…", "expiresAt": "2026-10-21T10:03:11.000Z", "storagePath": "media/…/1760000000_0.glb", "mimeType": "model/gltf-binary", "bytes": 212044, "triangles": 4064, "size": { "x": 5.89, "y": 12, "z": 5.63 } } ], "previews": [ { "name": "three-quarter", "url": "https://storage.example/signed/…", "expiresAt": "2026-10-21T10:03:11.000Z", "storagePath": "media/…/1760000000_11.png", "mimeType": "image/png", "width": 512, "height": 512 } ], "source": { "language": "javascript", "code": "function build(detail) { … }", "builder": "[email protected]" }, "summary": { "bbox": { "x": 5.89, "y": 12, "z": 5.63 }, "height": 12, "units": "metres", "pivot": "base-center", "up": "+Y", "front": "+Z", "triangles": { "lod0": 4064, "lod1": 2436, "lod2": 788 }, "materials": 7, "quality": "standard", "kind": "building", "critique": { "score": 8, "verdict": "accept", "notes": ["…"], "rounds": 2 }, "warnings": [] }, "models": ["openrouter/deepseek/deepseek-v4.1-flash-1017", "openrouter/anthropic/claude-haiku-5.5"], "costUSD": 0.061 }}lods[]— a self-contained GLB per detail level, with its triangle count and bounding box. Every level shares the same pivot (the model’s base centre is the origin: bbox centre in x/z, bbox minimum in y) and the same scale (one transform, computed fromlod0), so levels swap without popping. Y is up, units are metres, the front faces +Z. A level that ends up a different height thanlod0is called out insummary.warnings.previews[]— five 512×512 PNGs:front,three-quarter,side,back, and an orthographictop(a minimap icon; the model’s front is at the bottom of the image). Software-rendered with flat lighting, outlines, a shadow and a metre-scaled ground grid, not game-engine renders — use them as thumbnails and impostors, not as marketing images.source— the builder script. Send it back asbaseSourceto edit.summary.critique— the reviewer’s last verdict and notes;nullatdraftquality.scoreis out of 10 and is lenient: it catches gross faults (a missing roof, a floating part), not fine ones.models— which models served the request, provider-namespaced like themodelof an image result.
The signed urls expire after 60 minutes (expiresAt); copy the files into
your own storage. Re-polling a finished job within its 24 hours returns fresh
URLs for the same stored objects.
Failure, and what is charged
Section titled “Failure, and what is charged”A job ends failed with an error — and a failed job charges nothing, however
much work it did.
{ "status": "failed", "costUSD": 0, "error": { "code": "generation_failed", "message": "…" } }error.code |
Meaning |
|---|---|
generation_failed |
No script that builds came out after the repair attempts. |
budget_exceeded |
The spend cap was reached before a usable model existed. |
timeout |
The quality’s time limit was reached before a usable model existed. |
invalid_reference |
referenceImageUrl could not be fetched, or is not a PNG/JPEG/WebP/GIF under 8 MB. |
storage_failed |
The model was generated but could not be stored. |
interrupted |
The server working on the job stopped (a restart or deploy). Retry with the same Idempotency-Key. |
On success the charge is the sum of every model call the job made, at cost plus the
platform markup, itemised as separate usage rows under your tag/label.
Idempotency for jobs
Section titled “Idempotency for jobs”Send Idempotency-Key (your own job id, as for images). The job is the
idempotency record:
- the same key and body while the job is running returns the same job (
202,idempotent: true) and starts nothing; - after it finishes, the same call returns the finished job (
200) with the result — the replay never regenerates and never charges again; - a failed job frees its key: retrying starts a fresh attempt on the same
jobId, charged only if it succeeds; - the same key with a different body is
409 idempotency_key_reused.
Without a key every request is its own job.
Limits
Section titled “Limits”At most 3 jobs in flight per account on one API instance (and 8 per instance);
past that the answer is 429 too_many_jobs with retryAfterSeconds. The SDK
surfaces it as a rate-limit refusal.
const crew = new Tanzanite({ apiKey });const model = await crew.media.generateModel( { mediaType: "model", prompt: "a treasure chest", targetHeight: 0.8, projectId }, { idempotencyKey: jobId, onProgress: (job) => console.log(job.progress.stage) },);await fetch(model.lods[0].url); // GLB bytes — copy them into your own storagegenerateModel starts the job and polls it; startModel and job are the two
halves if you want to drive the wait yourself.
Rigged models
Section titled “Rigged models”With rig set the model is built on a skeleton and every level’s GLB is a skinned mesh with animation clips, ready for a game engine’s animation player.
humanoid,quadruped,birdandserpentstart from a ready skeleton with standard bone names and generatedidle,walk,run,attack,hitanddieclips (a bird alsofly). The writer resizes the skeleton, adds bones (ears, a jaw, extra tails) and may add or replace clips.custom: the writer designs the bones and writes every clip (robots, spiders, machines).- Skinning is rigid: each part follows one bone at full weight, so it suits stylized, blocky and mechanical creatures, not smooth organic bends. Left is +X, right is −X (the model faces +Z). Bone names and rest poses are identical at every detail level.
summary.riglists the template, the bones and the clips (name, seconds, loop).previewsalso holds contact sheets of the clips,pose-walk,pose-attack, … (four frames of a clip in one picture).- Use
kind: "creature"for animals and characters. There is no extra charge beyond the model calls.
Textures
Section titled “Textures”Any model may carry procedural surface textures (stripes, bricks, planks, scales, spots, speckle, noise, checker, gradient): the
writer draws them with Tex.make, the GLB embeds each as a base-colour PNG with TEXCOORD_0, and the material colour tints it. There
is no request field and no extra charge; ask for the look in prompt (“tiger-striped fur”, “red brick walls and a slate roof”).
At most 8 distinct textures per model, up to 256 × 256 px each.
Images are synchronous, not a job queue
Section titled “Images are synchronous, not a job queue”Image generation and vision return no job id: the request is held open and
answered with the result. There is a server-side ceiling of 90 seconds on an
image POST /v1/media/generate. (3D models are the exception, above.)
The ceiling is not arbitrary. The API sits behind a proxy whose origin read timeout would cut a longer request off before the platform could answer — the caller would get a bare gateway error, with no way to know whether the work had happened or whether they had been charged. So the platform answers first, at 90 seconds, with a clean, documented refusal:
{ "error": "media_timeout", "ceilingMs": 90000 }with status 504. Nothing is charged. Retry it — ideally with the same
Idempotency-Key.
Set your client timeout above 90 seconds
Section titled “Set your client timeout above 90 seconds”This is the single most common integration bug on this endpoint. Most HTTP clients default to 30 seconds; the Tanzanite SDK does too. A default-configured client will abort a perfectly healthy generation at 30 seconds, and your code will see a client-side timeout for a request the platform is still serving and will still complete.
Give this one call a timeout comfortably above the ceiling — 120 seconds is a
reasonable figure — and let the platform’s own media_timeout be the thing that
ends a call that is genuinely too slow.
const crew = new Tanzanite({ apiKey, timeout: 120_000 });Idempotency-Key
Section titled “Idempotency-Key”Optional, up to 200 characters, valid for 24 hours.
A synchronous spend endpoint has exactly one dangerous failure mode: your client gives up on a call the platform is still serving, you retry, and the user pays twice for one image. The idempotency key closes it.
Send your own job id — the identifier your system already has for the piece of work — rather than a random value generated at the call site. A random value generated per attempt is a different key on the retry and buys you nothing.
-H "Idempotency-Key: acme-job-7f3c1e"A replay returns the original result with idempotent: true, and charges
nothing. Treat that flag as informational; the response is otherwise identical
to the first one.
Reusing a key with a different request body is a collision, not a replay, and is refused:
{ "error": "idempotency_key_reused" }with status 409. Retrying it unchanged collides again — pick a fresh key. This
is deliberately not a silent success: answering a different question with a cached
result is worse than an error.
A retry that arrives while the first request is still running is refused rather than run a second time:
{ "error": "idempotency_in_progress", "retryAfterSeconds": 5 }with status 409. Nothing is charged for it. Retry with the same key after a
moment and you receive the first request’s result once it has finished.
The url expires; storagePath does not
Section titled “The url expires; storagePath does not”The signed url in a generation response is valid for 60 minutes, and
expiresAt tells you exactly when it stops working. storagePath is the durable
handle.
So: download the bytes, or copy them into your own storage, and do not persist
the url as though it were permanent. An app that stores signed URLs in its
database has built a feature that works for an hour and then reports broken images
to every user who comes back the next day.
A URL you have just received is fine to hand straight to a vision call, or to render in a page the user is looking at right now. It is the storing that is the mistake.
Images, speech and 3D models, in v1
Section titled “Images, speech and 3D models, in v1”mediaType accepts "image" and "speech" (synchronous) and "model" (an async
job). "video" and "audio" (music) are refused:
{ "error": "unsupported_media_type", "supported": ["image", "speech"], "asyncSupported": ["model"] }with status 400.
Why mediaType is a field
Section titled “Why mediaType is a field”Video and long audio cannot finish inside a 90-second synchronous ceiling, so
they need a different response shape: a 202 with a job id, plus a
GET /v1/media/jobs/:id to poll. 3D models already use exactly that shape, on
this same endpoint, keyed on mediaType — which is why mediaType is a body
field rather than a path segment. Had the route been POST /v1/media/image,
adding models would have meant a new URL, a second client method, and a fork in
every caller’s code. It was an additive change: a new accepted value, a new
response shape for that value, and nothing that existed before behaves
differently. Video and audio will arrive the same way.
Errors
Section titled “Errors”| Status | Body | Meaning |
|---|---|---|
400 |
{ "error": "unsupported_media_type" } |
video or audio (music). Not served in v1. |
400 |
{ "error": "invalid_request" } |
A field failed validation (the message names it). |
404 |
{ "error": "job_not_found" } |
Polling a job that does not exist, is older than 24 hours, or belongs to another connection. |
429 |
{ "error": "too_many_jobs", "retryAfterSeconds": 15 } |
Too many 3D model jobs in flight for this account. |
503 |
{ "error": "model_generation_unavailable" } |
3D model generation is not configured on this deployment. |
402 |
{ "error": "insufficient_credits" } |
The user’s balance is empty. Not retryable. |
402 |
{ "error": "host_app_spend_cap" } |
This connection’s daily cap is spent. Not “add credits”. |
403 |
{ "error": "insufficient_scope", "scope": "media.generate" } |
The connection never held the scope. |
409 |
{ "error": "idempotency_key_reused" } |
Same key, different body. Pick a fresh key. |
409 |
{ "error": "idempotency_in_progress" } |
The first request under this key is still running. Nothing charged. Retry with the same key. |
504 |
{ "error": "media_timeout", "ceilingMs": 90000 } |
Outran the ceiling. Nothing charged. Retry with the same key. |
Related
Section titled “Related”POST /v1/completions— the same shape for text: one model call, no harness, no agent. Reach for a session instead when the work needs tools, a plan, or more than one turn.