await in front of it:
httpx), Python 3.10 or newer. Infery and AsyncInfery
are twins by construction — the same 43 methods, the same parameters, the same
defaults — and the package’s own surface-parity test fails if one grows a method
the other does not. That is what makes “the same namespaces” checkable rather than
aspirational, and it is why every example below has an await form you do not
have to look up.
Why use it instead of the OpenAI SDK
You can point theopenai package at this gateway, and for chat and embeddings
that works. Past those, three things it cannot do for you:
Reach endpoints it has no methods for. Video, music, 3D, upscaling, workflows
and capabilities are not in the OpenAI API. Neither is POST /v1/images/edits in
the shape this gateway takes it — that endpoint accepts JSON with the image
base64-encoded, while the OpenAI SDK posts multipart.
Collect a deferred result. A slow media generation answers 504 with a
job_id and keeps working — and keeps billing. An OpenAI SDK caller sees a
failure and pays for nothing. This SDK collects the finished result from the job
endpoint for you.
Retry the right things. Rate limiting here answers 403, not 429, so the
OpenAI SDK’s backoff never fires. And a 500 on a billed POST must NOT be
retried blind, because it may arrive after your balance was already debited.
See OpenAI SDK compatibility for the
endpoint-by-endpoint answer.
What it covers
Every published operation. The namespaces:
Two names differ from the TypeScript client, and both differ because Python does:
three_d rather than threeD, and files.delete() rather than files.del() —
delete is a reserved word in TypeScript and is not one here, so the workaround
does not travel.
Every signature and every type is on the
method reference, generated from the package’s own
source.
Streaming
chat.completions.stream, music.stream and
workflows.runs.stream — are generators. Nothing is requested until you start
iterating, so an unconsumed one spends nothing, which is the safe direction for a
call billed per step. On AsyncInfery they are async generators: async for,
never await.
The last chunk before the stream ends carries credits_used and an empty
choices list. It is yielded like any other chunk rather than hidden, because it
is the only place the cost of a streamed call appears — which is also why the
guard above tests chunk["choices"] rather than indexing it.
A stream cut before its [DONE] terminator raises StreamTruncatedError after
delivering the chunks that did arrive. Catch it if partial output is useful, but
knowingly: the block most often cut is the trailing usage chunk.
One call for every media modality
Each media modality also has its own method —images.generate,
videos.generate, and the rest below. That is the wrong shape when the modality
is a runtime value: a model picked from the catalogue, a choice in a UI, a row
in a queue. Then every call site needs an if chain, and every one of those has
to be edited when a modality is added.
media.generate() takes the modality as data:
on_progress reports for video only
This one is a trap rather than a nuance, so it is here rather than in a footnote.
media.generate(on_progress=...) is accepted for all six modalities and fires
for video alone. Video is the only modality whose generation this method polls
itself; the other five poll inside their per-modality resource — images.generate,
music.generate, three_d.generate, videos.upscale, audio.speech.create —
and none of those takes a callback.
media.wait(on_progress=...) reports for all six, with the status and the
percentage the polled job reported — both job endpoints declare progress as a
required field. So when you need progress for something other than video, take
the handle and collect it:
The result shape
url, b64 and data is set on an artifact, and which one is a
property of the endpoint rather than of your request:
data, not bytes, because bytes is a builtin. “At most”, not “exactly”, is
the enforceable half: the SDK refuses to construct an artifact with two of them
set, because two could only ever be its own mapping bug, while zero is what a
response with an artifact and no deliverable maps to — and raising there would
cost you a result you have already been billed for.
upscale routes on the source, not the model: pass image_url or video_url.
The gateway refuses an image upscaler on the video route and vice versa, and the
model slug alone does not say which it is, so the SDK cannot guess — it asks.
What it gives up
Worth seeing before you choose it, because the named methods are still there and still better when you know the modality:- Named-argument checking beyond
modalityandmodel. A misspelledduration_secnodsreaches the wire, where the gateway ignores it and bills the model’s default length.videos.generate()namespromptandmodel, so it refuses a call missing one before it is sent. - Fields with no cross-modality meaning —
revised_prompt,lyrics,resolution. They are onresult.raw, which holds the untouched per-modality value rather than a re-parse of it.
MediaResult are on the
reference.
Media that takes minutes
Generation is submitted and awaited inside one request. If the gateway’s own wait runs out it answers504 with a job id and keeps working — the SDK collects the
result:
background=True
is accepted on ten methods, and on nine of them the handle arrives as an
exception rather than as a return value: collect_deferred re-raises the
gateway’s own JobDeferredError instead of polling it to completion.
media.generate is the tenth and the exception: it catches the deferral and
answers with a MediaResult whose job_id is set and whose artifacts are
empty, which is what lets one code path serve every modality.
background: true returns a result object carrying job_id; here the exception
is the carrier. It follows that a JobStatus in a return union —
ImageResponse | JobStatus and its siblings — is always a collected result:
the gateway deferred, the SDK polled to completion, and that is what it finished
with. It is never a handle you still have to poll.
jobs.wait raises JobFailedError when the job reaches failed, and
JobTimeoutError when max_wait elapses first. A JobTimeoutError is the client
giving up on watching, not the server giving up on running: the job was never
cancelled and is still billed, so poll it again later rather than starting a
second one.
Video generation is asynchronous by design and has its own poll:
audio.transcriptions.createis the only deferrable method whose return type excludesJobStatus. It maps the deferred job’s own payload back into aTranscriptionResult, because the deliverable is text and the durable path stores it as text rather than as a file artifact — which is what the TypeScript SDK does too.- It nonetheless accepts
background=True, where TypeScript deliberately does not. That is coherent, and it is the same mechanism as above:JobDeferredError.job_idcarries here what TypeScript had to encode in a return type.
Workflows
workflow_id, not pipeline_id. The request body says pipeline_id because the
rename stopped at the HTTP boundary; this SDK translates at its own boundary
instead of making you type the old name. A raw pipeline_id still reaches the
wire through **params — that is what forward compatibility costs — but passing
both is refused before the request is built, because **params is spread last
and the raw one would silently win, starting and billing a workflow other than
the one you named.
To watch a run as it happens, runs.stream() yields one tagged event per step.
Fourteen shapes, all discriminated on type, and the fourteenth is an escape
hatch so a new gateway event cannot break an existing loop:
runs.stream() fixes mode="stream". A mode= arriving through **params is
refused rather than forwarded: the gateway would run and settle the whole
workflow synchronously, and the SDK would then read that JSON body as SSE and
report a cut connection about a run you paid for in full.
Errors
InferyError, so one except catches
all of them — including the ones that are not APIError: APIConnectionError
and APITimeoutError (no response arrived), StreamTruncatedError (a stream
ended without [DONE]), and JobFailedError / JobTimeoutError (a polled job).
Status to class: AuthenticationError (401, and a 403 that is not a rate limit),
InsufficientCreditsError (402), RateLimitError (403 rate_limit_exceeded),
NotFoundError (404), ConflictError (409), and JobDeferredError for any
status whose body carries a job_id — checked first, because a job id means the
work exists whatever status carried it. Anything else lands on APIError itself,
which carries status, code, message, request_id and the parsed body.
err.request_id is the handle support uses to attribute a charge — quote it when
asking about a bill.
Branch on err.code when the class is not specific enough. ConflictError is the
clearest case: upload_in_progress means retry in a moment, while
idempotency_in_progress means a billed run is already in flight and a retry
could start a second one.
Bringing your own httpx client
Passhttp_client to reuse a connection pool, set proxies or limits, mount a
custom transport, or hand the SDK the same client the rest of your service
already uses:
http_client never closes it. The caller owns what the
caller made, so close() on the SDK client leaves your pool open for whatever
else is using it. A client that made its own closes that one.
Your timeouts win where they are set. The SDK passes its per-attempt
timeout on each request, so that still applies; the pool’s connect limits,
retries at the transport layer and proxy configuration are yours.
The base URL still comes from the SDK. base_url is applied per request, not
baked into the pool, so one httpx.Client can serve this SDK and your own calls
elsewhere.
For AsyncInfery, pass an httpx.AsyncClient — the annotation is on the
constructor, and handing it a synchronous one is a type error rather than a
runtime surprise.
What async with does to the pool
AsyncInfery is an async context manager, and leaving the block calls
aclose():
httpx.AsyncClient the SDK created — every keep-alive connection
in the pool goes with it. So async with belongs around the lifetime of the
work, not around each call: a client per request means a fresh TLS handshake per
request, and a gateway conversation is mostly TLS setup on short calls.
In a long-lived service, build one client at startup and close it at shutdown:
aclose, not close, on the async side: closing an httpx.AsyncClient is a
coroutine, and a method named close that has to be awaited is the shape that
gets called without await and silently leaks the pool. Infery has close()
and a plain with block for the same reasons in reverse.
If you passed your own http_client, async with still exits cleanly and your
pool stays open — see above.
Timeouts, polls and cancellation
Say the numbers rather than infer them from a stack trace:timeout is 310 seconds deliberately: above the gateway’s own 300-second wait for
a media generation, so a slow one reaches the deferral handoff instead of being
abandoned while it keeps billing. That is the right ceiling for generation and far
too long for a catalogue read — construct a second client for short calls, or pass
your own httpx client.
background=True hands back the job, not a result, and does it by raising:
JobDeferredError with a job_id on nine of the ten deferrable methods, or a
MediaResult carrying only job_id returned from media.generate. Nothing has
settled at that point, so there is no credits_used to report and no artifacts
to hand over — collect it with jobs.wait or media.wait.
Cancellation is the one place the two clients genuinely differ, because Python’s
mechanisms differ. On AsyncInfery, cancel the task — asyncio.timeout,
task.cancel(), or leaving an async with block — and the request raises
CancelledError at the next await point. On Infery there is no equivalent: the
call returns when it returns, bounded by timeout and max_wait.
Either way, cancelling does not cancel work the gateway has already started, and
does not refund it. An aborted generation is still billed.
Retries
Connection failures,408 / 429 / 500 / 502 / 503 and
409 upload_in_progress are retried with a short backoff — 0.5 s then 1 s —
but only on a GET or on the two endpoints that honour Idempotency-Key
(POST /v1/files, POST /v1/workflows/runs). On every other billed POST, a
500 is not retried: the gateway collapses several distinct upstream failures,
including ones that happen after your balance was debited, into the same generic
500, and retrying blind risks paying twice.
The backoff is short on purpose. The retryable set is GETs and two idempotent
POSTs, none of which is worth waiting seconds for.
Two things are never retried, whatever the method. 403 rate_limit_exceeded is a
60-second sliding window that counts refused requests too, so retrying inside it
pushes your own recovery further out. And any response carrying a job_id means
the work exists and is already billed — the SDK collects it rather than paying for
a second one.
Reference
- Package:
infery - Method reference — every signature, every type, generated from the package source
- Source:
sdks/python - Endpoint-by-endpoint OpenAI SDK compatibility