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 origin | confidential origin | |
|---|---|---|
| Learns your account | yes — the API key authenticates here | no |
| Learns the prompt or text | no | yes |
| Learns the priced shape | model, image size/format, speech character count, voice | the same, re-derived from the body |
| Credential it accepts | Authorization: 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_type —
image 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.ai | content answers 401 ticket_required without a one-use ticket |
control.anonrouter.ai | media 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
| Parameter | Supported | Behaviour |
|---|---|---|
model | yes | required; bound into the ticket |
prompt | yes | required; sent only to the confidential origin; max 10,000 characters |
size | yes | "WIDTHxHEIGHT", 128–2048 per side, default 1024x1024; bound into the ticket |
response_format | b64_json only | any other value is refused |
n | 1 only | n: 1 is accepted; anything else is refused — one image per ticket is what gets priced |
quality, style, user | no | refused 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
| Parameter | Supported | Behaviour |
|---|---|---|
model | yes | required; bound into the ticket |
input | yes | required; sent only to the confidential origin; max 20,000 characters |
voice | yes | optional; bound into the ticket. Omitting it binds "no voice", which is a distinct bound value |
response_format | mp3 only | any other value is refused |
speed | 1 only | refused 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.
| Field | Source |
|---|---|
selected_model | the exact provider/model that ran |
routing | how the model was chosen |
request_id | the id that appears in your ledger |
rate_limit | remaining 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.
| Operation | Bound facts |
|---|---|
| image | operation, model, width, height, response_format |
| speech | operation, 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.
| Code | Meaning | Charged? |
|---|---|---|
unsupported_request | refused locally, before any network call | no |
media_ticket_failed | the control origin would not mint | no |
ticket_binding_mismatch | the ticket bound facts you did not request — content was not sent | no |
ticket_rejected | expired, already spent, or drifted | no |
relay_refused | refused outright: no ticket, media_disabled, quota | no |
provider_failed | reached a provider and failed there | possibly |
response_invalid | the response was not well-formed media | the generation happened |
transport_failed | the connection failed mid-request | unknown |
cancelled / timeout | your abort signal, or a deadline | unknown |
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_url | https://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/confidentialVerify 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.
Verification and E2EE
Verify both hops of a confidential route with one verdict you can gate on, and run end-to-end-encrypted inference, with @anonrouter/confidential (JS/TS) and anonrouter-confidential (Python).
anonrouter-verify
The command both SDK packages install. It prints one JSON document and exits nonzero unless the assurance you asked for was established, so it can gate a deploy rather than only inform one.