Skip to content

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.

Scope: media.generate.

Terminal window
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
}
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.

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.

Terminal window
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).

Scope: media.vision.

Terminal window
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).

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:

Terminal window
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.

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 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.

{
"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 from lod0), so levels swap without popping. Y is up, units are metres, the front faces +Z. A level that ends up a different height than lod0 is called out in summary.warnings.
  • previews[] — five 512×512 PNGs: front, three-quarter, side, back, and an orthographic top (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 as baseSource to edit.
  • summary.critique — the reviewer’s last verdict and notes; null at draft quality. score is 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 the model of 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.

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.

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.

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 storage

generateModel starts the job and polls it; startModel and job are the two halves if you want to drive the wait yourself.

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, bird and serpent start from a ready skeleton with standard bone names and generated idle, walk, run, attack, hit and die clips (a bird also fly). 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.rig lists the template, the bones and the clips (name, seconds, loop). previews also 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.

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.

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.

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 });

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.

Terminal window
-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 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.

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.

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.

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.
  • 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.