Node.js SDK
The Pendra Node SDK is a TypeScript-first, zero-dependency client for sovereign UK inference. Streaming support, dual ESM/CJS. Node.js 20+.
Installation
$ npm install pendra
Zero runtime dependencies. View on npm.
Quick start
import Pendra from 'pendra';
const client = new Pendra({
apiKey: 'pdr_sk_...', // or set PENDRA_API_KEY env var
});
const response = await client.chat.completions.create({
model: 'qwen3.6:27b',
messages: [{ role: 'user', content: 'Hello!' }],
});
console.log(response.choices[0].message.content);
Streaming
Stream responses token by token using async iteration.
const stream = await client.chat.completions.create({
model: 'qwen3.6:27b',
messages: [{ role: 'user', content: 'Write a poem' }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}
Image generation
Generate images from a text prompt. Returns base64-encoded PNGs by
default — decode with Buffer.from(b64, 'base64') and write
to disk, or set response_format: 'url' when supported by the
model.
import { writeFileSync } from 'node:fs';
const response = await client.images.generations.create({
model: 'flux.1-schnell',
prompt: 'A red London double-decker bus at sunset',
});
const b64 = response.data[0].b64_json;
if (b64) {
writeFileSync('bus.png', Buffer.from(b64, 'base64'));
}
Image generation is non-streaming — the endpoint returns a single JSON response once the worker finishes.
Embeddings
Generate vector embeddings for retrieval, search, and RAG pipelines.
OpenAI-compatible — pass a string or array of strings and get back a
CreateEmbeddingResponse with one embedding per input.
const response = await client.embeddings.create({
model: 'nomic-embed-text',
input: ['The quick brown fox', 'jumps over the lazy dog'],
});
for (const item of response.data) {
console.log(item.index, (item.embedding as number[]).length, 'dims');
}
console.log(response.usage.prompt_tokens);
Any embedding model in the
Pendra catalogue
works — nomic-embed-text, qwen3-embedding,
bge-m3, embeddinggemma.
Audio transcription
Transcribe audio to text using Whisper-class models. Multipart upload —
pass a Blob/File directly, or a
{ filename, content } object where content is a
Blob, ArrayBuffer, or Uint8Array.
Files capped at 25 MB.
import { readFileSync } from 'node:fs';
const audio = readFileSync('meeting.mp3');
const result = await client.audio.transcriptions.create({
file: { filename: 'meeting.mp3', content: audio },
model: 'whisper-large-v3-turbo',
language: 'en',
});
console.log(result.text);
In the browser (or Bun), pass the File from an
<input type="file"> straight through. Set
response_format: 'srt' or 'vtt' to get
subtitles back instead of JSON:
// Browser / Bun: pass a Blob or File directly
const input = document.querySelector('input[type=file]');
const file = input.files[0]; // a File (which extends Blob)
const result = await client.audio.transcriptions.create({
file,
model: 'whisper-large-v3-turbo',
response_format: 'srt', // or 'vtt' for subtitles
});
console.log(result.text); // SRT string — text/srt/vtt are wrapped as { text }
Transcription is non-streaming. result.duration and
result.language are populated when the model reports them;
result.segments appears when
response_format: 'verbose_json'.
Text to speech
Turn text into speech with a speech model. You get a WAV file back
(24 kHz, 16-bit mono); leave out voice to use the model's
default.
import { writeFileSync } from 'node:fs';
const speech = await client.audio.speech.create({
model: 'your-speech-model', // an id from /models?type=speech
input: "Good morning, and welcome to today's briefing.",
});
writeFileSync('speech.wav', speech.audio); // a Uint8Array of WAV bytes
speech.arrayBuffer() works too, so code written for the OpenAI
SDK carries over. Speech isn't available with private inference yet, so the
call throws instead of sending your text unencrypted when private inference
is on.
Which worker served a request
Every reply names the worker that ran it on response.pendra?.worker:
its id (the same one worker_id pins a request to),
the name you gave it in the console, and the worker
version that served it (either can be null).
response.pendra?.request_id is the request's ID, worth logging
alongside it and quoting to support.
const response = await client.chat.completions.create({
model: 'qwen3.6:27b',
messages: [{ role: 'user', content: 'Hello!' }],
});
const worker = response.pendra?.worker;
console.log(worker?.id, worker?.name, worker?.version);
console.log(response.pendra?.request_id);
A stream names the worker on its last chunk, so read
stream.worker and stream.requestId once you've
finished iterating. Both are null until then.
const stream = await client.chat.completions.create({
model: 'qwen3.6:27b',
messages: [{ role: 'user', content: 'Write a poem about London.' }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}
console.log(stream.worker?.id, stream.requestId);
Embeddings, reranking, image generation, transcription and speech results
carry the same pendra field, and it works the same with private
inference turned on. Needs the pendra package 0.17 or later.
List models
const models = await client.models.list();
models.forEach((m) => console.log(m.id));
Migrating from OpenAI
The Pendra SDK mirrors the OpenAI interface. Two lines to switch — your existing code just works.
// Before
import OpenAI from 'openai';
const client = new OpenAI({ apiKey: 'sk-...' });
// After
import Pendra from 'pendra';
const client = new Pendra({ apiKey: 'pdr_sk_...' });
API reference
client.chat.completions.create()
Create a chat completion. Returns ChatCompletion or Stream.
| Parameter | Type | Description |
|---|---|---|
model | string | Model ID (e.g. "qwen3.6:27b") |
messages | Array | Chat messages with role and content |
stream | boolean? | Enable streaming (default false) |
temperature | number? | Sampling temperature (0–2) |
max_tokens | number? | Maximum tokens to generate |
top_p | number? | Top-p (nucleus) sampling value (1 = off) |
top_k | number? | Keep only the K most likely tokens (0 = off) |
min_p | number? | Drop tokens below min_p × the top token's probability |
frequency_penalty | number? | Penalise tokens by how often they've appeared (−2 to 2; 0 = off) |
presence_penalty | number? | Penalise tokens that have appeared at all (−2 to 2; 0 = off) |
repeat_penalty | number? | Classic repetition penalty (1 = off; e.g. 1.1–1.3) |
seed | number? | Seed the sampler for reproducible output |
stop | string | string[]? | Stop sequence(s) |
tools | Array? | Functions the model may call — see Tool calling. Calls come back on message.tool_calls. |
tool_choice | string | object? | 'auto', 'none', or a specific function to force |
parallel_tool_calls | boolean? | Allow the model to request several tool calls in one reply |
response_format | object? | { type: 'json_object' } or a json_schema — see Structured outputs |
enable_thinking | boolean? | Set false to make a reasoning model answer directly — see Thinking |
reasoning_effort | string? | 'low' / 'medium' / 'high', or 'none' to turn thinking off |
logit_bias | object? | Bias specific tokens, keyed by token id (−100 to 100) |
logprobs, top_logprobs | boolean?, number? | Return token log-probabilities, model permitting |
repeat_last_n | number? | How many recent tokens repeat_penalty looks back over (default 64) |
worker_id | string? | Pin the request to a specific worker that serves this model (sent as the X-Pendra-Worker-Id header). Defaults to automatic routing. |
Anything else you pass is sent to the API as-is, so a Chat Completions field newer than your SDK version still reaches the model. TypeScript flags an undeclared key written straight into the call — that's what catches typos — so pass a newer field through a variable or a cast when you mean it.
messages takes a content string, or an array of
{ type: 'text' } / { type: 'image_url' }
parts for vision models. On the way
back, a reasoning model's working arrives on
message.reasoning_content (mirrored on
message.reasoning, and streamed as
delta.reasoning_content), separate from the answer in
message.content.
Replies also carry a Pendra-specific pendra field alongside
choices. Read response.pendra?.notice when an
answer looks wrong for no obvious reason — a
truncated_during_reasoning code is the model telling you it
spent the whole max_tokens budget thinking and never reached
an answer, which otherwise just looks like an empty
message.content:
const response = await client.chat.completions.create({
model: 'ornith-1.5:9b',
messages: [{ role: 'user', content: 'Which is heavier, 1kg of steel or 1kg of feathers?' }],
max_tokens: 200,
});
if (response.pendra?.notice?.code === 'truncated_during_reasoning') {
// Retry with a bigger budget, or with thinking off.
console.log(response.pendra.notice.message);
console.log(response.usage?.completion_tokens_details?.reasoning_tokens); // where it went
}
The other codes include truncated_during_structured_output,
reasoning_budget_reached,
truncated_at_reasoning_budget,
truncated_at_default_max_tokens,
structured_output_not_json and
strict_schema_not_enforced — see
Notices for what each one
means. The same field carries
pendra.web_tool_steps when the serving worker has
web tools enabled.
When streaming, the last few chunks report on the request rather than
continuing the answer: one carries chunk.usage, one carries
chunk.pendra?.timing (ttft_ms,
tokens_per_second, queue_wait_ms,
worker_queue_wait_ms), and a notice arrives on its own chunk
too — so check chunk.pendra on every chunk, not just the last.
Those chunks have no text, which is why the streaming example reads content
with chunk.choices[0]?.delta?.content rather than indexing
choices[0] unconditionally.
client.images.generations.create()
Generate images from a text prompt. Returns Promise<ImageResponse>.
| Parameter | Type | Description |
|---|---|---|
model | string | Image model ID (e.g. "sdxl-turbo") |
prompt | string | Text description of the image to generate |
n | number? | Number of images, 1–4 (default 1) |
size | string? | Dimensions as WIDTHxHEIGHT (defaults to the model's native resolution, e.g. 512x512 for SD 1.5, 1024x1024 for SDXL/FLUX) |
response_format | string? | "b64_json" (default) or "url" |
num_inference_steps | number? | Diffusion steps, 1–50; defaults to the model's recommended step count (e.g. 4 for sdxl-turbo, 20 for qwen-image-2512) |
seed | number? | Random seed for reproducibility |
negative_prompt | string? | Text to avoid in the generated image |
client.embeddings.create()
Create embeddings. Returns Promise<CreateEmbeddingResponse>.
| Parameter | Type | Description |
|---|---|---|
model | string | Embedding model ID (e.g. "nomic-embed-text") |
input | string | string[] | Text to embed. Accepts a single string or a batch. |
encoding_format | "float" | "base64"? | Defaults to float |
dimensions | number? | Output dimensionality (Matryoshka models like nomic-embed-text) |
user | string? | Optional end-user identifier |
client.models.list()
Returns an array of Model objects. Each model has id,
object, created, and owned_by
fields.
client.audio.transcriptions.create()
Transcribe an audio file. Returns TranscriptionResponse.
| Parameter | Type | Description |
|---|---|---|
file | AudioFileInput | Blob/File, or { filename, content }. ≤ 25 MB. |
model | string | Transcription model id (e.g. "whisper-large-v3-turbo") |
language | string? | ISO-639-1 language hint, optional |
prompt | string? | Biasing prompt (vocabulary, formatting), optional |
response_format | string? | "json" (default), "text", "srt", "vtt", or "verbose_json" |
temperature | number? | Sampling temperature. 0 (the default) is the most accurate; above 0 the decoder samples instead of using the beam |
beam_size | number? | Decoding beam width, 1–8. Omit for the accuracy-first default (beam search); pass 1 to decode greedily. Applies at temperature 0. |
timestamp_granularities | ("word" | "segment")[]? | verbose_json only |
client.audio.speech.create()
| Parameter | Type | Description |
|---|---|---|
model | string | Speech model ID |
input | string | Text to speak, up to 4,096 characters |
voice | string? | One of the model's preset voices; defaults to the model's default_voice |
response_format | 'wav'? | The only format |
worker_id | string? | Pin the request to a specific worker that serves this model |
Resolves to a SpeechResponse: audio (the WAV
bytes), duration_ms, pendra, and
arrayBuffer().
Configuration
| Option | Env var | Default |
|---|---|---|
apiKey | PENDRA_API_KEY | — |
baseURL | — | https://api.pendra.ai |
timeout | — | 120000 (ms). Raise to up to 1800000 if you use non-streaming with slow or large models — the server supports requests up to 30 minutes. A streaming response can run for as long as it needs to: timeout only limits how long the stream can go without receiving any data, after which it fails with APIConnectionError. If you stop iterating a stream early (for example with break), the request is cancelled and generation stops. |
Error handling
All exceptions extend APIError.
| Exception | Status | When |
|---|---|---|
AuthenticationError | 401 | Invalid or missing API key |
RateLimitError | 429 | Too many requests |
APIStatusError | 4xx/5xx | Any other non-2xx response |
APIStreamError | 200 | A streaming reply, or a slow non-streaming one, failed after the 200 was sent — see below |
APIConnectionError | — | Network or connection failure |
APIResponseValidationError | any | A reply arrived but wasn't the shape the SDK expects |
err.message is always a readable sentence. Some errors carry
more than a sentence — when your organisation requires end-to-end
encryption and a request is sent without it, for example, the error also
says why in a machine-readable type. The message stays readable, and the
original error is on err.detail (the whole response body is on
err.body), so your code can branch on it:
try {
await client.chat.completions.create({ model: "ornith-1.5:9b", messages });
} catch (err) {
if (err instanceof APIStatusError) {
console.error(err.message); // "This organisation requires end-to-end encrypted requests. …"
const detail = err.detail as { error?: { type?: string } } | undefined;
if (detail?.error?.type === "e2e_required") {
client = new Pendra({ privateInference: true }); // retry encrypted
}
}
}
Failures during a stream
A streaming reply has to send its status code before the model has
generated anything, so if something goes wrong once generation is under way
— the GPU worker crashing, the request timing out, your fleet being busy —
it can't come back as an HTTP error code. The SDK throws
APIStreamError from the iterator instead, at the point the
failure happened. Anything the model already sent you is yours to keep:
import { APIStreamError } from "pendra";
let answer = "";
try {
const stream = await client.chat.completions.create({
model: "ornith-1.5:9b",
messages: [{ role: "user", content: "Write me a limerick." }],
stream: true,
});
for await (const chunk of stream) {
answer += chunk.choices[0]?.delta?.content ?? "";
}
} catch (err) {
if (err instanceof APIStreamError) {
// `answer` holds the partial reply that arrived before the failure.
console.error(`Stream failed: ${err.message}`);
console.error(`Quote this to support: ${err.requestId}`);
} else {
throw err;
}
}
err.requestId and err.workerId identify the exact
inference that failed, so quote them if you report the problem. Check
err.status to decide whether retrying is worth it — but check
it for undefined first, because some failures (including a
timeout) arrive with no status code at all. (If none of
your workers are online when you start a stream, the call throws a plain
APIStatusError with a 503 before any tokens
arrive, not an APIStreamError.)
if (err.status === 429 || err.status === 503) {
await retryAfterBackoff(); // fleet busy, or its worker went offline
} else if (err.status === undefined) {
// Timed out, or a failure the server couldn't classify. Retrying once is
// usually reasonable; read err.message to see what happened.
console.error(err.message);
} else {
throw err; // e.g. 502 — a worker failed, retrying won't help
}
Note that a stream reports being at capacity this way rather than as a
RateLimitError — by the time the queue wait is over, the 200
has already been sent — so back off on the status code, not the error class.
The same applies to a non-streaming request that runs for more than about
80 seconds — a long embeddings batch, an image, a long JSON-format
transcription. Pendra
sends the 200 early to keep the connection open, so a failure after that
point also arrives as APIStreamError, thrown by the
create call itself, with the same fields. You never get an
empty result in its place.
Replies that don't parse
APIResponseValidationError means the reply reached you but
couldn't be read as a chat completion — usually a proxy between you and
Pendra returning its own error page. It is deliberately not an
APIConnectionError: retrying won't help, because the same body
will fail to parse again. If you retry on connection failures, let this one
through. The offending body is quoted in the message (and in full on
err.body) so you can see what actually came back.