anonrouterdocs
SDKs

Images and speech

Generate images and synthesize speech across AnonRouter's two-origin privacy split with client.images.generate and client.audio.speech.create.

client.images.generate(...) and client.audio.speech.create(...) generate an image or synthesize speech across AnonRouter's two-origin split, in both JavaScript and Python. Parameter names follow the OpenAI API, so a working call ports over mostly unchanged.

One SDK call is two HTTP requests to two different hosts, and that is the entire point.

What each host learns

  YOU ──API key, model, size/voice, character COUNT──▶  control.anonrouter.ai
                                                         (control origin)
       ◀──────── single-use ticket, 30s ─────────────────┘

  YOU ──ticket, prompt or text─────────────────────▶  api.anonrouter.ai
                                                         (confidential origin)
       ◀──────── image bytes / audio bytes ─────────────┘
control originconfidential origin
Learns your accountyes — the API key authenticates hereno
Learns the prompt or textnoyes
Learns the priced shapemodel, image size/format, speech character count, voicethe same, re-derived from the body
Credential it acceptsAuthorization: Bearer <key>the single-use ticket, and nothing else

Neither host holds both halves. The control plane can say "this account paid for one 1024×1024 image" and cannot say what was in it. The confidential plane holds the prompt and cannot say whose it was.

This is not end-to-end encryption

These methods live on the same client as chat(), so it is worth being exact. chat() on an E2EE provider encrypts in your process — AnonRouter receives ciphertext. Ticketed media does not: the prompt reaches the confidential origin as plaintext. What protects it is the origin split plus the Intel TDX enclave that origin runs in, which you can verify yourself before you send anything.

That is a real property and a weaker one than E2EE chat. The difference is what it would take for AnonRouter to read a media prompt: shipping a different build into the CVM. Not undetectable — the measurements would change and verifyGateway() would stop passing — but possible, whereas on an E2EE route the relay holds ciphertext no matter what code it runs. If you need "cannot read it even if they shipped code to try" rather than "would be caught", media does not meet that today.

Quickstart

The production origins are the defaults, so a key is the whole configuration.

import { writeFile } from "node:fs/promises";
import { createClient } from "@anonrouter/confidential";

const client = createClient({ apiKey: process.env.ANONROUTER_API_KEY! });

const image = await client.images.generate({
  model: "alibaba/z-image-turbo",
  prompt: "a lighthouse in a storm, painted in oils",
  size: "1024x1024",
});
await writeFile("out.png", image.data[0].bytes);
console.log(image.selected_model, image.data[0].mime_type);

const speech = await client.audio.speech.create({
  model: "venice/kokoro-text-to-speech",
  input: "The quick brown fox jumps over the lazy dog.",
  voice: "af_sky",
});
await writeFile("out.mp3", speech.audio);

The model IDs above are illustrative

Catalogue IDs change as routes are added and retired, so do not copy them blindly. List what your key can actually reach and filter on model_typeimage for image generation, tts for speech:

curl -s https://control.anonrouter.ai/v1/models \
  -H "Authorization: Bearer $ANONROUTER_API_KEY" \
  | jq -r '.data[] | select(.model_type=="image" or .model_type=="tts") | "\(.model_type)\t\(.id)"'

Your key goes to the control origin here, which is where a content-free catalogue read belongs. api.anonrouter.ai also answers /v1/models, but only in compatibility mode and only for a key that has it enabled.

Available voices are per-model and reported by the catalogue. You can also browse the models page.

The official OpenAI SDK cannot do this

This is the reason these methods exist rather than a base-URL swap.

An OpenAI client is built around one base URL and one credential. Every request it makes carries the API key to the host it is configured with. To run the exchange above it would have to send a content-free request to host A with the key, read a ticket out of the response, send the content to host B with the ticket and without the key, and bind and re-check the ticket's facts between the two. No configuration of openai — Python or Node — does that.

If you point a stock OpenAI client at…What happens
api.anonrouter.aicontent answers 401 ticket_required without a one-use ticket
control.anonrouter.aimedia answers 503 media_disabled — the control plane serves no media content at all

Both failures are the design working. The SDK methods on this page perform the exchange automatically.

Compatibility mode is a different, lower-privacy option

AnonRouter also offers an OpenAI compatibility mode where one endpoint accepts an ordinary OpenAI-shaped request and performs the mint internally. It is a real option and it is not what these methods use. The difference is not cosmetic: in that mode a single AnonRouter-operated service receives your API key and your prompt in the same request, and does the split on your behalf on the far side of that boundary. In the two-origin exchange the split happens in your process, before anything leaves it. The SDK never selects compatibility mode implicitly — there is no fallback to it and no flag that enables it. If a call cannot be made privately it fails.

Compatibility matrix

Unsupported OpenAI parameters are refused, not dropped. Silently ignoring n: 4 would hand back one image and charge for one while you believed you asked for four.

images.generate

ParameterSupportedBehaviour
modelyesrequired; bound into the ticket
promptyesrequired; sent only to the confidential origin; max 10,000 characters
sizeyes"WIDTHxHEIGHT", 128–2048 per side, default 1024x1024; bound into the ticket
response_formatb64_json onlyany other value is refused
n1 onlyn: 1 is accepted; anything else is refused — one image per ticket is what gets priced
quality, style, usernorefused as unknown keys

Returns created, model, and data[] with b64_json, mime_type, and the decoded bytes (bytes in JS, data in Python).

audio.speech.create

ParameterSupportedBehaviour
modelyesrequired; bound into the ticket
inputyesrequired; sent only to the confidential origin; max 20,000 characters
voiceyesoptional; bound into the ticket. Omitting it binds "no voice", which is a distinct bound value
response_formatmp3 onlyany other value is refused
speed1 onlyrefused otherwise — playback rate is not part of the authorized work

Returns the complete audio buffer (audio) and its content_type. This endpoint returns one response body rather than a stream, and the SDK does not pretend otherwise. Python adds result.write_to(path).

Response metadata, on both

Kept because it is how you reconcile a generation against your bill.

FieldSource
selected_modelthe exact provider/model that ran
routinghow the model was chosen
request_idthe id that appears in your ledger
rate_limitremaining requests and tokens after the call
provider_blurred (image)the provider's safety layer blurred the result
provider_content_violation (image)the provider flagged a policy violation

What the ticket binds

The confidential origin independently re-derives every bound fact from the body you send and answers 409 on any drift. The SDK checks the mint's echo first, so a mismatch fails on the content-free half of the exchange and the prompt is never sent at all.

OperationBound facts
imageoperation, model, width, height, response_format
speechoperation, model, exact input character count, voice, response_format

The speech character count is UTF-16 code units

Speech is priced per character, so the ticket binds the exact count and the relay rejects a body that differs by one.

AnonRouter counts UTF-16 code units. Python's len() counts code points. They disagree on every emoji and every astral-plane character: "Hi 😀" is 4 to len() and 5 to the server.

The Python SDK therefore counts UTF-16 code units, exported as utf16_length. A client that used len() would mint a ticket bound to the wrong number and get a 409 on exactly the inputs containing emoji. If you are building your own client against the raw API, this is the detail that will bite you.

Errors

Typed, with a machine-readable code and diagnostics that carry no prompt, key, or ticket. The distinction that matters is whether money moved.

CodeMeaningCharged?
unsupported_requestrefused locally, before any network callno
media_ticket_failedthe control origin would not mintno
ticket_binding_mismatchthe ticket bound facts you did not request — content was not sentno
ticket_rejectedexpired, already spent, or driftedno
relay_refusedrefused outright: no ticket, media_disabled, quotano
provider_failedreached a provider and failed therepossibly
response_invalidthe response was not well-formed mediathe generation happened
transport_failedthe connection failed mid-requestunknown
cancelled / timeoutyour abort signal, or a deadlineunknown

Both MediaError types subclass the package's ConfidentialError, so an existing catch / except ConfidentialError keeps catching everything.

import { MediaError } from "@anonrouter/confidential";

try {
  await client.images.generate({ model, prompt });
} catch (error) {
  if (error instanceof MediaError) {
    // `provider_failed` is the only code that may correspond to work a provider
    // actually attempted and charged for.
    console.error(error.code, error.diagnostics.request_id);
    // Header values are already redacted.
    console.error(error.diagnostics.headers);
  }
}

No automatic retry

A failed media POST is never retried. A generation is billed on the provider attempt, so a transparently retried POST is a second charge for a call you made once. This holds for 500, 502, 503, 429, 408 and transport failures alike. Tickets are single-use, so a retry would fail with ticket_rejected anyway — but the rule is enforced regardless, and pinned by tests that count POSTs.

If you want to retry, do it deliberately in your own code, having decided that a duplicate generation is acceptable.

Nothing sensitive in an error

Messages and diagnostics never contain the prompt, the input text, the API key, or the ticket. Diagnostics carry the origin, path, status, the machine-readable error type, the request id, and the request headers with every credential value replaced by <redacted> — names preserved, values gone. An error body that quotes your prompt back is discarded rather than passed through. redactHeaders / redact_headers is exported so the same rule can apply to anything you log yourself.

Configuration

Option (JS / Python)Default
inferenceBaseUrl / inference_base_url (alias: baseUrl / base_url)https://api.anonrouter.ai
controlBaseUrl / control_base_urlhttps://control.anonrouter.ai

Two rules, both fail-closed:

  • Media requires two distinct origins. If the control and inference origins are the same host, media is refused — that host would receive the API key and the prompt together, which is the exact linkage the ticket prevents. Verification and E2EE chat are unaffected, because in E2EE chat the relay receives ciphertext, so a single origin still never holds readable content.
  • Two origins that disagree are refused, not resolved by precedence, and so is an origin supplied as an empty string. The ambiguity is about where prompts go.

The one documented exception is a loopback local-test override: with allowInsecureHttp / allow_insecure_http set and a loopback host, a single origin is permitted, because a developer running both roles on one machine has no privacy boundary to collapse. It cannot be reached for a remote host.

Checking the contract yourself, for free

Every claim about how the two origins behave can be checked without a key and without spending anything. These probes carry no credential and no ticket, so the relay refuses them before any provider is dispatched:

# The confidential origin serves media and fails closed without a ticket.
curl -s -X POST https://api.anonrouter.ai/v1/images/generations \
  -H 'content-type: application/json' -d '{}'
# {"error":{"message":"A single-use ticket is required","type":"ticket_required",...}}

# The control origin serves no media content at all.
curl -s -X POST https://control.anonrouter.ai/v1/audio/speech \
  -H 'content-type: application/json' -d '{}'
# {"error":{"message":"media generation is disabled","type":"media_disabled"}}

The SDK ships these as live tests. They need no key and no DCAP engine, so anyone can reproduce them:

ANONROUTER_LIVE_GATEWAY_ORIGIN=https://api.anonrouter.ai \
ANONROUTER_LIVE_PUBLIC_ORIGIN=https://control.anonrouter.ai \
  npx vitest run test/live-media.test.ts     # in js/confidential

Verify the plane before you send a prompt

Media content goes to the same origin the gateway attestation covers, so you can establish the plane first and only then generate. See verification.

On this page